When a small infrastructure team starts automating servers with Ansible, the first win is usually a shared repository of playbooks. The second challenge arrives quickly: who can run those playbooks, how are secrets protected, and how do you make the latest deployment history visible without giving everyone shell access to the automation host? Semaphore UI, often called Ansible Semaphore, gives teams a lightweight web interface for inventories, project templates, schedules, access control, and audited task runs. This production guide deploys Semaphore UI on Ubuntu with Docker Compose, PostgreSQL for durable state, and Caddy for automatic HTTPS.
The goal is a practical setup you can hand to a team: playbooks live in Git, application state lives in PostgreSQL, secrets are isolated in a root-owned environment file, Caddy terminates TLS, and the host exposes only HTTP and HTTPS. The example uses a single VM because that is the most common starting point, but the same pattern can be moved behind a load balancer or expanded with external PostgreSQL later.
Architecture and flow overview
The deployment has four moving parts. Caddy listens on ports 80 and 443, obtains certificates from Let's Encrypt, and proxies traffic to the Semaphore container on the internal Docker network. Semaphore stores users, projects, templates, schedules, and task history in PostgreSQL. Your Git provider remains the source of truth for playbooks, while Semaphore checks out repositories when templates run. Administrators access the web UI through HTTPS, assign users to projects, configure keys or tokens, and run tasks against inventories.
In production, treat Semaphore as a control plane. A compromised admin account can run automation against your servers, so the host deserves the same hardening as CI/CD infrastructure: MFA at the identity provider where possible, least-privilege SSH keys, restricted firewall rules, tested backups, and clear task ownership.
Prerequisites
- Ubuntu 22.04 or 24.04 server with at least 2 CPU cores, 2 GB RAM, and 20 GB disk.
- A DNS record such as
semaphore.example.compointing to the server. - Ports 80 and 443 open from the internet; SSH restricted to trusted IPs where possible.
- A Git repository containing your Ansible playbooks, roles, and inventory examples.
- A non-root sudo user for installation work.
Step-by-step deployment
1) Install Docker, Compose, and baseline tools
Start with a patched host and install the packages needed for Docker's official repository. The commands below keep the base system minimal and predictable.
sudo apt update
sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg ufw git openssl
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin
Manual copy fallback: if the copy button is stripped by the editor, select the command block and copy it manually.
2) Create the project layout
Keep application files under /opt/semaphore, with data and backups separated from Compose definitions. Restrict permissions before writing secrets.
sudo mkdir -p /opt/semaphore/{postgres,caddy,data,backups}
sudo chown -R root:root /opt/semaphore
sudo chmod 750 /opt/semaphore
cd /opt/semaphore
Manual copy fallback: if the copy button is stripped by the editor, select the command block and copy it manually.
3) Generate secrets and environment values
Semaphore needs a database password, an admin bootstrap password, and an encryption key for stored secrets. Generate them locally on the server and keep the file readable only by root.
cd /opt/semaphore
sudo tee .env >/dev/null <
Manual copy fallback: if the copy button is stripped by the editor, select the command block and copy it manually. Replace the domain and email before starting services.
4) Create Docker Compose services
The Compose file defines PostgreSQL, Semaphore, and Caddy on one private network. PostgreSQL is not published to the host. Semaphore is only reachable by Caddy, reducing accidental exposure of the internal service port.
sudo tee docker-compose.yml >/dev/null <<'EOF'
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
semaphore:
image: semaphoreui/semaphore:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
SEMAPHORE_DB_DIALECT: postgres
SEMAPHORE_DB_HOST: postgres
SEMAPHORE_DB_PORT: 5432
SEMAPHORE_DB_USER: ${POSTGRES_USER}
SEMAPHORE_DB_PASS: ${POSTGRES_PASSWORD}
SEMAPHORE_DB: ${POSTGRES_DB}
SEMAPHORE_ADMIN: ${SEMAPHORE_ADMIN}
SEMAPHORE_ADMIN_PASSWORD: ${SEMAPHORE_ADMIN_PASSWORD}
SEMAPHORE_ADMIN_NAME: Platform Admin
SEMAPHORE_ADMIN_EMAIL: ${SEMAPHORE_ADMIN_EMAIL}
SEMAPHORE_ACCESS_KEY_ENCRYPTION: ${SEMAPHORE_ACCESS_KEY_ENCRYPTION}
SEMAPHORE_MAX_PARALLEL_TASKS: 4
ANSIBLE_HOST_KEY_CHECKING: "True"
volumes:
- ./data:/var/lib/semaphore
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- semaphore
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy:/data
EOF
Manual copy fallback: if the copy button is stripped by the editor, select the command block and copy it manually.
5) Configure Caddy and the firewall
Caddy handles HTTPS automatically. Keep the proxy simple at first, then add identity-aware access policies or SSO in front if your organization requires it.
cd /opt/semaphore
source .env
sudo tee Caddyfile >/dev/null <
Manual copy fallback: if the copy button is stripped by the editor, select the command block and copy it manually.
6) Start the stack and create your first project
Bring the services up, then open the URL and log in with the generated admin password from .env. Create a project, connect your Git repository, add an inventory, and define a template that runs a safe playbook such as a package update check or service status report.
cd /opt/semaphore
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
sudo docker compose logs --tail=80 semaphore
Manual copy fallback: if the copy button is stripped by the editor, select the command block and copy it manually.
7) Add backups and restore rehearsal
Task history and project metadata are only useful if they survive a disk failure. Back up PostgreSQL daily and copy the archives to a separate system. A backup that has never been restored is only a hopeful file, so schedule a quarterly restore rehearsal.
sudo tee /usr/local/bin/backup-semaphore >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/semaphore
source .env
stamp=$(date -u +%Y%m%dT%H%M%SZ)
sudo docker compose exec -T postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "backups/semaphore-${stamp}.sql.gz"
find backups -type f -name 'semaphore-*.sql.gz' -mtime +14 -delete
EOF
sudo chmod 750 /usr/local/bin/backup-semaphore
sudo /usr/local/bin/backup-semaphore
(crontab -l 2>/dev/null; echo '17 2 * * * /usr/local/bin/backup-semaphore') | crontab -
Manual copy fallback: if the copy button is stripped by the editor, select the command block and copy it manually.
Configuration and secrets handling best practices
Use project-specific SSH keys rather than one broad automation key. In Semaphore, store keys only for the project that needs them, and prefer read-only Git deploy keys for repository access. If a playbook needs cloud credentials, inject them through Semaphore environment variables or a secrets manager, not through committed inventory files. Rotate the generated admin password after first login, disable unused accounts, and review project membership monthly.
Pin container image versions after the initial rollout. The latest tag is convenient for a guide, but production change control is easier when upgrades are deliberate. Before each upgrade, run a PostgreSQL backup, export critical project settings, read release notes, and test a representative playbook template.
Verification checklist
docker compose psshows PostgreSQL, Semaphore, and Caddy as running or healthy.- The public URL loads over HTTPS with a valid certificate for your domain.
- You can log in, create a project, connect a Git repository, and run a harmless playbook.
- Semaphore task logs show expected Ansible output and no leaked secrets.
- The backup command creates a compressed SQL file and old backups rotate correctly.
- Firewall rules expose only SSH, HTTP, and HTTPS unless your operating model requires more.
Common issues and fixes
1) Caddy cannot obtain a certificate
Check that DNS points to the server and that ports 80 and 443 are reachable from the internet. Certificate issuance fails when another service already binds those ports or when a cloud firewall blocks inbound traffic.
2) Semaphore starts but login fails
Review docker compose logs semaphore and confirm the admin values were present on first boot. If you changed bootstrap credentials after initial database creation, create or reset users through Semaphore's documented admin tooling rather than expecting environment variables to overwrite existing accounts.
3) Git checkout fails inside tasks
Verify deploy keys, repository URLs, and known-host handling. For private repositories, use a dedicated read-only deploy key and test access from a temporary container or from Semaphore's key configuration workflow.
4) Playbooks work manually but fail in Semaphore
Compare the working directory, environment variables, Ansible version, and SSH key. Semaphore runs tasks from its own container context, so implicit files from your shell session are not available unless mounted or configured in the project.
5) Backups exist but restores fail
Test restore into a clean PostgreSQL container. Keep the matching application version available during restore rehearsals, and document the exact commands used to rebuild the stack from backups.
FAQ
Can I use MySQL instead of PostgreSQL?
Semaphore supports multiple database backends, but PostgreSQL is a strong default for operational reliability, tooling, backups, and future migration options.
Should Semaphore be exposed publicly?
Only expose it publicly if you need remote team access and have strong account controls. For sensitive environments, place it behind VPN, SSO, or an identity-aware proxy.
How should I organize projects?
Separate projects by blast radius: production infrastructure, staging infrastructure, security jobs, and application maintenance should not all share the same keys and permissions.
Can Semaphore replace a CI/CD system?
It can schedule and run Ansible tasks, but it is best treated as an operations automation console. Keep software build pipelines in CI/CD and use Semaphore for controlled infrastructure runbooks.
What should I monitor?
Monitor HTTPS availability, container restarts, disk growth, failed task counts, PostgreSQL backup freshness, and unusual admin logins or unexpected template executions.
How do I safely upgrade the stack?
Take a database backup, pin the target image version, read release notes, upgrade during a maintenance window, run a known safe template, and keep the previous image tag ready for rollback.
Related guides
- Deploy Gitea with Docker Compose + Traefik + PostgreSQL
- Deploy OpenBao with Docker Compose + Caddy + Integrated Raft
- Deploy n8n with Docker Compose + Traefik + PostgreSQL
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.