Deploy Semaphore UI on a VPS
A web interface for Ansible, Terraform, OpenTofu, and PowerShell — single Go binary, runs on 2 GB of RAM, fronted by Nginx with PostgreSQL and a Let's Encrypt cert. The lightweight alternative to AWX.
At a Glance
| Project | Semaphore UI (semaphoreui/semaphore) |
| License | MIT |
| Recommended Plan | Cloud VPS 2 GB / 2 vCPU (4 GB for larger playbooks) |
| OS | Ubuntu 24.04 LTS (also Debian 13) |
| Database | PostgreSQL 16 (BoltDB possible but not recommended) |
| Estimated Setup Time | 30–45 minutes |
Why Semaphore over AWX
A single Go binary that idles at 50–80 MB instead of AWX's 8 GB+ stack. You give up enterprise RBAC and workflow approval gates; you get a service that installs in minutes, runs on cheap hardware, and stays out of your way. The right trade for most teams managing fewer than ~100 hosts.
Initial Server Hardening
apt update && apt -y full-upgrade
adduser vanessa
usermod -aG sudo vanessa
mkdir -p /home/vanessa/.ssh
cp ~/.ssh/authorized_keys /home/vanessa/.ssh/
chown -R vanessa:vanessa /home/vanessa/.ssh
chmod 700 /home/vanessa/.ssh
chmod 600 /home/vanessa/.ssh/authorized_keysPermitRootLogin no
PasswordAuthentication nosystemctl restart ssh
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo apt -y install fail2ban
sudo systemctl enable --now fail2banSemaphore listens on TCP 3000; we'll proxy through Nginx and never expose 3000 directly.
Install Ansible and Supporting Tools
Semaphore doesn't bundle Ansible — it shells out to whatever ansible-playbook it finds. The Ubuntu 24.04 distro package is current enough.
sudo apt -y install ansible git curl wget gnupg \
software-properties-common ca-certificates
ansible --version
sudo apt -y install python3-pip python3-venv python3-apt sshpasssshpass is optional but useful if any inventory entries authenticate with passwords rather than keys.
Install and Configure PostgreSQL
sudo apt -y install postgresql postgresql-contrib
sudo systemctl enable --now postgresqlsudo -u postgres psql <<'SQL'
CREATE DATABASE semaphore;
CREATE USER semaphore WITH ENCRYPTED PASSWORD 'REPLACE_WITH_A_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE semaphore TO semaphore;
ALTER DATABASE semaphore OWNER TO semaphore;
SQLDon't skip this: PostgreSQL 15+ locks down the public schema by default, which trips up Semaphore's first-run migrations.
sudo -u postgres psql -d semaphore -c "GRANT ALL ON SCHEMA public TO semaphore;"Install Semaphore
The repo path moved from ansible-semaphore/semaphore to semaphoreui/semaphore. The block below detects the latest release and the right architecture:
cd /tmp
VER=$(curl -sL https://api.github.com/repos/semaphoreui/semaphore/releases/latest \
| grep tag_name | head -1 | sed 's/.*"v\([^"]*\)".*/\1/')
ARCH=$(dpkg --print-architecture)
echo "Installing Semaphore v$VER ($ARCH)"
wget "https://github.com/semaphoreui/semaphore/releases/download/v${VER}/semaphore_${VER}_linux_${ARCH}.deb"
sudo dpkg -i "semaphore_${VER}_linux_${ARCH}.deb"
semaphore versionsudo useradd --system --create-home --home-dir /var/lib/semaphore \
--shell /usr/sbin/nologin semaphore
sudo mkdir -p /etc/semaphore /var/lib/semaphore/playbooks
sudo chown -R semaphore:semaphore /etc/semaphore /var/lib/semaphoreRun the Setup Wizard
sudo -u semaphore semaphore setupAnswer the prompts:
| What database to use? | 3 (PostgreSQL) |
| DB Hostname | 127.0.0.1:5432 |
| DB User / Name | semaphore / semaphore |
| DB Password | the value from Step 3 |
| Playbook path | /var/lib/semaphore/playbooks |
| Web Root URL | https://semaphore.example.com |
| Config output path | /etc/semaphore/config.json |
Get the Web Root URL right now — Semaphore uses it for webhook callbacks, OIDC redirects, and notification links. The generated access_key_encryption in config.json protects every credential in the Key Store. Back up config.json.
Create a systemd Service
The .deb doesn't ship a unit file — small wart, easy fix:
[Unit]
Description=Semaphore UI
Documentation=https://docs.semaphoreui.com
Wants=network-online.target postgresql.service
After=network-online.target postgresql.service
[Service]
Type=simple
User=semaphore
Group=semaphore
ExecReload=/bin/kill -HUP $MAINPID
ExecStart=/usr/bin/semaphore server --config=/etc/semaphore/config.json
SyslogIdentifier=semaphore
Restart=always
RestartSec=10s
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/semaphore /etc/semaphore
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now semaphore
sudo systemctl status semaphore
curl -I http://127.0.0.1:3000If it dies on startup, journalctl -u semaphore -n 50 first. Most failures at this stage are PostgreSQL schema permissions (revisit Step 3) or a typo in config.json.
Put Nginx in Front
sudo apt -y install nginxserver {
listen 80;
server_name semaphore.example.com;
client_max_body_size 50M;
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 for live task log streaming
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}sudo ln -s /etc/nginx/sites-available/semaphore /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxThe WebSocket headers and long timeouts matter — without them the live task log view drops on long playbook runs.
Add a Let's Encrypt Certificate
sudo apt -y install certbot python3-certbot-nginx
sudo certbot --nginx -d semaphore.example.com \
--redirect --agree-tos -m you@example.com --no-eff-email
sudo systemctl list-timers | grep certbotBrowse to https://semaphore.example.com and sign in with the admin user from the wizard.
First Run in the UI
Five concepts have to fit together. Build them in this order and the UI makes sense:
- Project — tenant boundary; inventories/repos/templates don't cross
- Key Store — SSH keys, vault passwords, sudo passwords, cloud API tokens (encrypted with the access key from
config.json) - Repositories — the Git repo with your playbooks; cloned fresh before each task
- Inventories — Static (paste) or File (in repo, version-controlled — preferred)
- Task Templates — bind a repo + playbook + inventory + variable group; click Run
First run fails with Host key verification failed? Set host_key_checking = False in /etc/ansible/ansible.cfg, or ship a known_hosts file. Disabling key checking is fine on trusted networks, unwise on the public internet.
Backups
State lives in three places: the PostgreSQL DB (projects, templates, history, encrypted credentials), /etc/semaphore/config.json (the encryption key — without it the DB backup is useless for credentials), and /var/lib/semaphore/playbooks (just clones — restorable from your Git remotes).
sudo -u postgres pg_dump -Fc semaphore \
> /var/backups/semaphore-$(date +%F).dumpCron the dump, copy config.json with permissions preserved, and ship both off-host with restic, BorgBackup, or rclone.
Hardening Notes
- Even with Nginx out front, gate the login page with Cloudflare Access, an nginx allow/deny block, or basic auth
- The REST API uses bearer tokens — automation keeps working as long as you allowlist token holders
- Treat
access_key_encryptionlike any production secret — back it up to a password manager or vault - Don't use Semaphore to manage the Semaphore host. A botched
localhostplaybook can take down the very service running it
