How to Install Semaphore CI on Ubuntu 24.04 — Self-Hosted Ansible Task Runner
If you manage more than a handful of Linux servers, you already know Ansible. What you probably want next is a clean web UI to organize playbooks, store inventories and SSH keys, schedule jobs, and show non-Ansible teammates a "Run" button instead of a terminal. That is exactly what Semaphore UI provides. This tutorial walks you through installing Semaphore on an Ubuntu 24.04 VPS end to end — from the first SSH connection to a scheduled Ansible playbook running against your fleet, protected by HTTPS.
Prefer a different CI? Compare with Jenkins, Drone CI, Woodpecker CI, or Gitea Actions.
Table of Contents
What is Semaphore UI?
Semaphore UI (the open-source project lives at github.com/ansible-semaphore/semaphore) is a modern, browser-based interface for Ansible. Instead of running ansible-playbook from your laptop, you define projects inside Semaphore and let it execute playbooks on demand or on a schedule. Every run is logged, every secret is encrypted at rest, and every user has a role.
Semaphore supports four task types: Ansible playbooks, Terraform plans and applies, shell scripts (Bash), and tfvars-driven modules. For most sysadmins and DevOps engineers, the Ansible integration is the main draw — it turns your existing playbook repository into a point-and-click automation platform without changing a single line of YAML.
Under the hood, Semaphore is a single Go binary that talks to a SQL database (BoltDB, MySQL, or PostgreSQL), serves a Vue-based web UI, exposes a REST API, and shells out to ansible-playbook, terraform, or bash for the actual work. It is lightweight, easy to back up, and straightforward to upgrade.
Why Self-Host an Ansible Task Runner?
You could run Ansible from a laptop or a shared jump host forever, but a self-hosted task runner quickly pays for itself:
- Centralized secrets. SSH private keys, vault passwords, and cloud API tokens live in one encrypted store instead of scattered across developer laptops.
- Audit trail. Every playbook run is logged with the user, the inventory targeted, start/end time, and full stdout. When something breaks at 3 a.m., you know exactly who ran what.
- Scheduled automation. Cron triggers let you run patching, log rotation, certificate renewal, or backup playbooks without maintaining a separate crontab on a bastion host.
- Non-sysadmin access. Junior team members, support engineers, and even product managers can trigger safe, pre-approved playbooks from the UI without being handed SSH keys.
- No vendor lock-in. Unlike Ansible Automation Platform (formerly Ansible Tower, Red Hat subscription), AWX (Kubernetes-heavy), or cloud services like Ansible-as-a-service, Semaphore runs on a single Ubuntu box and costs nothing beyond the VPS.
- Data sovereignty. Inventories, playbooks, secrets, and run history never leave your infrastructure. This matters for GDPR, HIPAA, SOC 2, and any internal policy that treats credentials as sensitive.
- Fits small and medium fleets. Semaphore is happy managing 5 servers or 500. It does not require Kubernetes, Redis, or an object store.
Prerequisites
Before you start, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access from your workstation
- A domain name (for example,
semaphore.example.com) with an A record pointing to your VPS IP - Open firewall ports 22 (SSH), 80 (HTTP, for Certbot validation), and 443 (HTTPS)
- At least 2 GB of RAM and 20 GB of disk — Semaphore itself is tiny, but Ansible + a database + the OS comfortably fit this footprint
Recommended Plan: CloudCore Starter>
Semaphore is lightweight, and our CloudCore Starter plan is a strong fit for a single-team Ansible controller:>
- 4 vCPU cores
- 8 GB RAM
- 75 GB NVMe SSD
- Unmetered bandwidth
- EUR 7.99/month>
That leaves headroom for the database, concurrent playbook runs, and a few hundred managed hosts. Scale up later only if you start running long-lived Terraform plans or parallel fleet-wide rollouts.
Connect to your server:
ssh root@your-server-ipStep 1: Update the System and Install Ansible
Bring the system up to date and install Ansible plus supporting tools. Semaphore does not bundle Ansible — it calls the ansible-playbook binary that you provide.
sudo apt update && sudo apt upgrade -y
sudo apt install -y ansible git curl gnupg ca-certificates python3-pip sshpassConfirm the Ansible version:
ansible --versionExpected output:
ansible [core 2.16.3]
config file = /etc/ansible/ansible.cfg
python version = 3.12.3If the kernel was upgraded, reboot and reconnect:
sudo rebootStep 2: Install and Configure the Database
Semaphore supports BoltDB (embedded file), MySQL, and PostgreSQL. For anything beyond a single-user test, pick MySQL or PostgreSQL. We show both — use whichever you already operate.
Option A: MySQL (MariaDB)
sudo apt install -y mariadb-server
sudo systemctl enable --now mariadb
sudo mysql_secure_installationAccept the defaults for socket authentication, set a strong root password, and answer "Y" to the remaining hardening questions.
Create the Semaphore database and user:
sudo mysql -u root -pCREATE DATABASE semaphore CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'semaphore'@'localhost' IDENTIFIED BY 'ChangeThisStrongPassword!';
GRANT ALL PRIVILEGES ON semaphore.* TO 'semaphore'@'localhost';
FLUSH PRIVILEGES;
EXIT;Option B: PostgreSQL
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresqlsudo -u postgres psqlCREATE DATABASE semaphore;
CREATE USER semaphore WITH ENCRYPTED PASSWORD 'ChangeThisStrongPassword!';
GRANT ALL PRIVILEGES ON DATABASE semaphore TO semaphore;
\c semaphore
GRANT ALL ON SCHEMA public TO semaphore;
\qStore the credentials in a password manager — you will paste them into the Semaphore setup wizard in a moment.
Step 3: Install Semaphore from the .deb Package
The Semaphore project publishes official .deb packages on GitHub Releases. Fetch the latest release URL from github.com/ansible-semaphore/semaphore/releases, then download and install it.
cd /tmp
SEMAPHORE_VERSION="2.10.32"
curl -LO "https://github.com/ansible-semaphore/semaphore/releases/download/v${SEMAPHORE_VERSION}/semaphore_${SEMAPHORE_VERSION}_linux_amd64.deb"
sudo dpkg -i "semaphore_${SEMAPHORE_VERSION}_linux_amd64.deb"Expected output:
Selecting previously unselected package semaphore.
Unpacking semaphore (2.10.32) ...
Setting up semaphore (2.10.32) ...Confirm the binary is on your PATH:
semaphore versionExpected output:
v2.10.32Create a dedicated system user and directory structure so Semaphore does not run as root:
sudo useradd --system --home /opt/semaphore --shell /usr/sbin/nologin semaphore
sudo mkdir -p /etc/semaphore /var/lib/semaphore /var/log/semaphore /tmp/semaphore
sudo chown -R semaphore:semaphore /etc/semaphore /var/lib/semaphore /var/log/semaphore /tmp/semaphoreStep 4: Run the Semaphore Setup Wizard
Semaphore ships with an interactive setup wizard that generates a JSON config file. Run it as the semaphore user so file ownership is correct from the start.
sudo -u semaphore semaphore setupThe wizard asks a series of questions. Sensible answers are shown below — adapt the DB choice and paths to your environment.
Hello. You will now be guided through a setup to:Set up configuration for a MySQL/MariaDB database Set up a path for your playbooks (auto-created) Run database migrations Set up initial Semaphore user & password What database to use: 1 - MySQL 2 - BoltDB 3 - PostgreSQL (default 1): 1
db Hostname (default 127.0.0.1:3306): db User (default root): semaphore db Password: ChangeThisStrongPassword! db Name (default semaphore):
Playbook path (default /tmp/semaphore): /var/lib/semaphore
Public URL (optional, example: https://<hostname>/semaphore): https://semaphore.example.com
Enable email alerts? (yes/no) (default no): no Enable telegram alerts? (yes/no) (default no): no Enable slack alerts? (yes/no) (default no): no
Config output directory (default /home/semaphore): /etc/semaphore
Running: mkdir -p /etc/semaphore.. Configuration written to /etc/semaphore/config.json.. Pinging db.. Running DB Migrations.. Migrations Finished
The wizard writes an encrypted config to /etc/semaphore/config.json containing the database credentials, encryption keys, and cookie secrets. Lock it down:
sudo chown semaphore:semaphore /etc/semaphore/config.json
sudo chmod 600 /etc/semaphore/config.jsonStep 5: Create the Admin User and Start the Service
Create the first admin user non-interactively so you can log into the UI:
sudo -u semaphore semaphore user add \
--admin \
--login admin \
--email [email protected] \
--name "Admin User" \
--password 'ChangeThisAdminPassword!' \
--config /etc/semaphore/config.jsonExpected output:
User admin <[email protected]> added!Now create a systemd unit so Semaphore starts at boot and restarts on failure:
sudo tee /etc/systemd/system/semaphore.service > /dev/null <<'EOF' [Unit] Description=Semaphore Ansible UI Documentation=https://docs.semaphoreui.com After=network.target mariadb.service postgresql.service[Service] Type=simple User=semaphore Group=semaphore ExecStart=/usr/bin/semaphore service --config=/etc/semaphore/config.json Restart=on-failure RestartSec=5s WorkingDirectory=/var/lib/semaphore Environment=HOME=/var/lib/semaphore ProtectSystem=full NoNewPrivileges=true PrivateTmp=true
[Install] WantedBy=multi-user.target EOF
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now semaphore
sudo systemctl status semaphoreExpected output:
● semaphore.service - Semaphore Ansible UI
Loaded: loaded (/etc/systemd/system/semaphore.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-04-16 12:00:00 UTC; 3s ago
Main PID: 4321 (semaphore)Semaphore listens on port 3000 by default. A quick local check:
curl -I http://127.0.0.1:3000Expected:
HTTP/1.1 200 OKDo not expose port 3000 to the internet — we will put it behind Nginx with TLS next.
Step 6: Configure Nginx with Let's Encrypt TLS
Install Nginx and Certbot:
sudo apt install -y nginx certbot python3-certbot-nginxCreate a reverse proxy site:
sudo tee /etc/nginx/sites-available/semaphore > /dev/null <<'EOF' server { listen 80; server_name semaphore.example.com;location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for live task output streaming proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s;
client_max_body_size 50m; } } EOF
sudo ln -s /etc/nginx/sites-available/semaphore /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx
Issue a certificate:
sudo certbot --nginx -d semaphore.example.com --redirect --agree-tos -m [email protected] --no-eff-emailCertbot rewrites the Nginx config to add HTTPS, a redirect from HTTP, and a systemd timer that auto-renews every 60 days. Verify the renewal will work:
sudo certbot renew --dry-runNow open https://semaphore.example.com in your browser. Log in with the admin account you created in Step 5. You should land on the empty projects page.
Step 7: Create Your First Project
Click New Project on the landing page. A project is a logical container for playbooks, inventories, keys, environments, and task templates.
Fill in:
- Name:
Infrastructure - Alerts: off for now
- Max parallel tasks:
4(how many playbooks can run simultaneously)
Add a Git Repository
Semaphore runs playbooks directly from a Git repo. Go to Repositories → New Repository:
- Name:
infra-playbooks - Git URL:
[email protected]:yourorg/infra-playbooks.git - Branch:
main - Access Key: (we will add a deploy key in the next step)
Step 8: Add Inventory, Environment, and Keys
Key Store
Semaphore's Key Store holds three types of secrets, all encrypted with the AES key generated during semaphore setup:
- SSH Key — private keys used to connect to managed hosts
- Login with password — username/password pairs (for Git HTTPS, for
ansible_become_pass, etc.) - None — a placeholder when no auth is needed
sudo -u semaphore ssh-keygen -t ed25519 -f /var/lib/semaphore/.ssh/id_ed25519 -N ''
sudo cat /var/lib/semaphore/.ssh/id_ed25519.pubPush the public key to your managed hosts (or bake it into your base image). Then in the UI, go to Key Store → New Key:
- Name:
ansible-ssh - Type:
SSH Key - Private Key: paste the contents of
/var/lib/semaphore/.ssh/id_ed25519
Inventory
Go to Inventory → New Inventory:
- Name:
production - Type:
Static(paste YAML/INI directly) orFile(use an inventory file from the repo) - User Credentials:
ansible-ssh(the key you just added)
[webservers] web01.example.com web02.example.com[dbservers] db01.example.com
[all:vars] ansible_user=deploy ansible_python_interpreter=/usr/bin/python3
Environment
Environments are JSON blobs of Ansible extra_vars and shell env vars injected at run time — perfect for per-stage configuration (dev / staging / production).
Go to Environment → New Environment:
- Name:
production-env - Extra Variables (JSON):
{
"deploy_env": "production",
"app_version": "1.4.2",
"notify_slack": true
}- Environment Variables (JSON):
{
"ANSIBLE_HOST_KEY_CHECKING": "False",
"ANSIBLE_FORCE_COLOR": "True"
}Save. Everything in the first block is passed to Ansible as --extra-vars; everything in the second is exported as a shell environment variable for the run.
Step 9: Build a Task Template and Run a Playbook
A Task Template glues together a repository, an inventory, an environment, and a playbook path into a re-runnable job.
Go to Task Templates → New Template → Ansible Playbook:
- Name:
Deploy Web Tier - Playbook Filename:
playbooks/web-deploy.yml(relative to the repo root) - Inventory:
production - Repository:
infra-playbooks - Environment:
production-env - Vault Password: (optional — reference a Key Store entry if your playbook uses Ansible Vault)
- Arguments (JSON array):
["--limit", "webservers", "--diff"] - Allow CLI args in Survey: checked, if you want users to override args at run time
- Survey variables: add fields (for example
app_version) that users must fill in before launching
The live task view streams ansible-playbook output line by line. When the task completes, you get a green/red badge, duration, and a full log you can download. Every run is stored in the Activity tab, attributed to the user who launched it.
If you just want to smoke-test before pointing Semaphore at real hosts, create a trivial playbook in your repo:
# playbooks/smoke.yml
- name: Smoke test
hosts: all
gather_facts: false
tasks:
- name: Ping
ansible.builtin.ping:
- name: Show hostname
ansible.builtin.command: hostname
changed_when: falseRun it against a one-host inventory and verify you see PLAY RECAP with ok=2 at the bottom of the task log.
Step 10: Schedule a Playbook with Cron
Semaphore's scheduler uses standard cron syntax to run any task template automatically.
Go to Schedules → New Schedule:
- Task Template:
Deploy Web Tier - Cron Expression:
0 3 *(every day at 03:00 server time) - Active: on
| Expression | Meaning |
|---|---|
/15 | Every 15 minutes |
0 | Top of every hour |
0 3 | Daily at 03:00 |
0 3 0 | Every Sunday at 03:00 |
0 3 1 | First of every month at 03:00 |
Triggered by: schedule.Typical scheduled playbooks:
- Nightly OS patching with
ansible.builtin.apt: upgrade=distand a reboot role - Weekly TLS certificate audit that reports hosts with certs expiring in less than 30 days
- Hourly drift detection running in check mode (
--check --diff) against your baseline role
Hardening and Backups
Firewall
Lock down everything except SSH, HTTP (for Certbot), and HTTPS:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw statusTwo-Factor Authentication
Semaphore supports OIDC (OpenID Connect) for SSO. Point it at Keycloak, Authentik, or Google Workspace so logins flow through your existing identity provider with MFA already enforced. The auth block in /etc/semaphore/config.json is where OIDC providers are declared — see the official OIDC docs for provider-specific blocks.
Backups
Three things must be backed up to recover a Semaphore installation:
/etc/semaphore/config.json (contains DB credentials and the AES encryption key — without it, stored SSH keys and vault passwords cannot be decrypted).Example nightly MySQL dump:
sudo tee /usr/local/bin/semaphore-backup.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F)
BACKUP_DIR=/var/backups/semaphore
mkdir -p "$BACKUP_DIR"
mysqldump --single-transaction --quick --default-character-set=utf8mb4 \
-u semaphore -p'ChangeThisStrongPassword!' semaphore \
| gzip > "$BACKUP_DIR/semaphore-$STAMP.sql.gz"
cp /etc/semaphore/config.json "$BACKUP_DIR/config-$STAMP.json"
chmod 600 "$BACKUP_DIR"/*
find "$BACKUP_DIR" -mtime +30 -delete
EOF
sudo chmod +x /usr/local/bin/semaphore-backup.shAdd a crontab entry:
echo "15 2 * root /usr/local/bin/semaphore-backup.sh" | sudo tee /etc/cron.d/semaphore-backupCopy backups off-box (S3, Backblaze B2, a second VPS) so a single-server failure does not take your automation with it.
Upgrades
Semaphore upgrades are trivial — download the new .deb, install it, restart the service:
SEMAPHORE_VERSION="2.10.40"
cd /tmp
curl -LO "https://github.com/ansible-semaphore/semaphore/releases/download/v${SEMAPHORE_VERSION}/semaphore_${SEMAPHORE_VERSION}_linux_amd64.deb"
sudo dpkg -i "semaphore_${SEMAPHORE_VERSION}_linux_amd64.deb"
sudo systemctl restart semaphoreDB migrations run automatically on startup. Always back up the database before a major version bump.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
dial tcp 127.0.0.1:3306: connect: connection refused in semaphore logs | MySQL not running, or DB credentials wrong in config.json | sudo systemctl status mariadb; re-run semaphore setup or edit the mysql block in /etc/semaphore/config.json |
| Web UI loads but login returns 500 | Config file lost the encryption key between restarts | Restore /etc/semaphore/config.json from backup — do not regenerate it, or all encrypted keys in the DB become unreadable |
Permission denied (publickey) when the playbook runs | The SSH key in Key Store is not authorized on the target host | ssh-copy-id the corresponding public key, or add it to the target's ~/.ssh/authorized_keys |
ansible-playbook: command not found in task output | Ansible missing on the Semaphore server | sudo apt install -y ansible |
| Live output hangs / WebSocket errors in browser console | Nginx missing the Upgrade/Connection headers | Re-apply the Nginx block from Step 6 and reload |
| Scheduled task never fires | Schedule exists but its task template was deleted or the schedule is inactive | Check Schedules list — inactive toggles are gray; recreate if needed |
Error 1045: Access denied for user 'semaphore'@'localhost' | DB password changed but config.json not updated | Edit /etc/semaphore/config.json, restart with sudo systemctl restart semaphore |
sudo journalctl -u semaphore -fFAQ
How is Semaphore UI different from Ansible AWX or Ansible Automation Platform?
AWX and Ansible Automation Platform (Red Hat's commercial product) are powerful but heavy — AWX requires Kubernetes or OpenShift and a PostgreSQL operator, while AAP needs a Red Hat subscription. Semaphore UI is a single Go binary with a SQL database, installs in 15 minutes on any Linux box, and scales fine for teams managing a few hundred hosts. You give up advanced features like workflow DAGs with conditional branching and the Red Hat support contract, but you gain dramatic operational simplicity. For most small-to-medium teams, Semaphore is the pragmatic choice.
Can Semaphore run Terraform and shell scripts, not just Ansible?
Yes. When creating a task template, the template type dropdown includes Ansible Playbook, Terraform, Tofu (OpenTofu), and Bash script. Terraform templates let you run plan and apply with state stored in the repo or a remote backend. Bash templates simply execute a script from the connected repository with any environment variables and survey inputs you define. This makes Semaphore a reasonable light alternative to Jenkins for mixed IaC workloads.
Does Semaphore support LDAP or SSO?
Yes. Semaphore supports LDAP/AD and OpenID Connect (OIDC). OIDC is the modern path — it works with Keycloak, Authentik, Auth0, Azure AD, Google Workspace, and Okta. Configure the provider block in /etc/semaphore/config.json, restart the service, and the login page gains an "SSO" button. Local username/password accounts can coexist with SSO, which is useful for break-glass admin access.
How many hosts can a single Semaphore server manage?
Semaphore itself is the bottleneck only for scheduling and log storage — the real constraint is your Ansible playbooks. A CloudCore Starter (4 vCPU, 8 GB RAM) comfortably runs playbooks against 200-500 managed hosts with forks: 20 and a handful of concurrent task templates. If you routinely run wide gather_facts playbooks across 1000+ hosts or several Terraform plans in parallel, move to a larger plan with more RAM and vCPUs. The database and UI scale easily; the limit is CPU during fact gathering.
Can I trigger Semaphore tasks from CI/CD or external systems?
Yes. Semaphore exposes a REST API and every task template has a dedicated webhook URL. From GitHub Actions, GitLab CI, or any CI system, POST to https://semaphore.example.com/api/project/<id>/templates/<id>/run with an API token in the Authorization header, and Semaphore will enqueue the run. This lets you treat Semaphore as the "deployer" stage after your CI does the build/test — very common pattern with Woodpecker CI or Gitea Actions driving Semaphore for the final apply step.
What happens to my encrypted keys if I lose config.json?
They become unrecoverable. The AES encryption key for the Key Store lives in /etc/semaphore/config.json as access_key_encryption. If the file is lost and not backed up, every SSH key, vault password, and login stored in Semaphore is permanently undecryptable — you must delete them and re-enter. This is why the backup script in the hardening section copies config.json alongside the database dump. Keep it on encrypted offsite storage.
Should I use BoltDB, MySQL, or PostgreSQL?
BoltDB (a single file) is fine for a single user evaluating Semaphore on a laptop. It does not support concurrent access well and has no replication story. MySQL/MariaDB is the most common Semaphore deployment and what the maintainers test most heavily — pick this if you do not already have a PostgreSQL footprint. PostgreSQL works equally well and is the right choice if your team already operates Postgres (shared monitoring, backups, replication). For anything production, pick one of the SQL databases.
Next Steps
With Semaphore running, protected by TLS, and executing scheduled playbooks, here is where to go next:
- Wire up Slack or email alerts in project settings so failed runs page the on-call engineer instead of silently going red in the UI.
- Add OIDC SSO so team members log in with your existing identity provider — no more shared
adminpassword. - Version-control your Ansible roles properly. Split
playbooks/androles/into separate repos, pin role versions inrequirements.yml, and let Semaphore pull the exact revisions it runs. - Pair Semaphore with a build-focused CI. Use Jenkins, Drone CI, Woodpecker CI, or Gitea Actions for compilation and tests, then have them trigger Semaphore via webhook for the deploy stage.
- Document your task templates. Use the description field on every template to explain what it does, which inventory it targets, and who to call if it breaks at 3 a.m.
- Read the official docs. The install, configuration, and administration reference lives at semaphoreui.com/install and the full manual at docs.semaphoreui.com.
Need a VPS sized right for Semaphore?>
Our CloudCore Starter plan ships with 4 vCPU, 8 GB RAM, and 75 GB NVMe — the exact shape recommended above for a single-team Ansible controller managing up to a few hundred hosts. EUR 7.99/month, deployed in under a minute, SSH-ready for this tutorial.>
Launch your Starter VPS and finish this install before your next coffee.