Ansible UI
    Task Orchestrator
    MIT

    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

    ProjectSemaphore UI (semaphoreui/semaphore)
    LicenseMIT
    Recommended PlanCloud VPS 2 GB / 2 vCPU (4 GB for larger playbooks)
    OSUbuntu 24.04 LTS (also Debian 13)
    DatabasePostgreSQL 16 (BoltDB possible but not recommended)
    Estimated Setup Time30–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.

    1

    Initial Server Hardening

    Update + create sudo user
    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_keys
    /etc/ssh/sshd_config
    PermitRootLogin no
    PasswordAuthentication no
    Apply + firewall + fail2ban
    systemctl 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 fail2ban

    Semaphore listens on TCP 3000; we'll proxy through Nginx and never expose 3000 directly.

    2

    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.

    Ansible + Python tooling
    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 sshpass

    sshpass is optional but useful if any inventory entries authenticate with passwords rather than keys.

    3

    Install and Configure PostgreSQL

    Install + start
    sudo apt -y install postgresql postgresql-contrib
    sudo systemctl enable --now postgresql
    Create the database and user
    sudo -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;
    SQL

    Don't skip this: PostgreSQL 15+ locks down the public schema by default, which trips up Semaphore's first-run migrations.

    Grant the public schema
    sudo -u postgres psql -d semaphore -c "GRANT ALL ON SCHEMA public TO semaphore;"
    4

    Install Semaphore

    The repo path moved from ansible-semaphore/semaphore to semaphoreui/semaphore. The block below detects the latest release and the right architecture:

    Pull and install the .deb
    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 version
    Create a system user (don't run Semaphore as root)
    sudo 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/semaphore
    5

    Run the Setup Wizard

    As the semaphore user
    sudo -u semaphore semaphore setup

    Answer the prompts:

    What database to use?3 (PostgreSQL)
    DB Hostname127.0.0.1:5432
    DB User / Namesemaphore / semaphore
    DB Passwordthe value from Step 3
    Playbook path/var/lib/semaphore/playbooks
    Web Root URLhttps://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.

    6

    Create a systemd Service

    The .deb doesn't ship a unit file — small wart, easy fix:

    /etc/systemd/system/semaphore.service
    [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.target
    Enable + verify
    sudo systemctl daemon-reload
    sudo systemctl enable --now semaphore
    sudo systemctl status semaphore
    curl -I http://127.0.0.1:3000

    If 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.

    7

    Put Nginx in Front

    Install + site config
    sudo apt -y install nginx
    /etc/nginx/sites-available/semaphore
    server {
        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;
        }
    }
    Enable
    sudo ln -s /etc/nginx/sites-available/semaphore /etc/nginx/sites-enabled/
    sudo nginx -t && sudo systemctl reload nginx

    The WebSocket headers and long timeouts matter — without them the live task log view drops on long playbook runs.

    8

    Add a Let's Encrypt Certificate

    Issue + auto-renew
    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 certbot

    Browse to https://semaphore.example.com and sign in with the admin user from the wizard.

    9

    First Run in the UI

    Five concepts have to fit together. Build them in this order and the UI makes sense:

    1. Project — tenant boundary; inventories/repos/templates don't cross
    2. Key Store — SSH keys, vault passwords, sudo passwords, cloud API tokens (encrypted with the access key from config.json)
    3. Repositories — the Git repo with your playbooks; cloned fresh before each task
    4. Inventories — Static (paste) or File (in repo, version-controlled — preferred)
    5. 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.

    10

    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).

    Daily pg_dump
    sudo -u postgres pg_dump -Fc semaphore \
      > /var/backups/semaphore-$(date +%F).dump

    Cron 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_encryption like any production secret — back it up to a password manager or vault
    • Don't use Semaphore to manage the Semaphore host. A botched localhost playbook can take down the very service running it