Do you have a fleet of Proxmox VMs and LXCs and have to manually update each? With the rising threat of AI-driven vulnerability exploitation, it is all the harder these days to stay on top of patching. While recently in version 2 onwards, Patchmon implemented patching (which is great), even the most current version at the time of writing this article (2.0.2) does a very basic job with it.
For example, what if you want to take a snapshot before a patching job is done? Or perform checks before running updates (such as whether there is enough disk space available)? What if you would like to run analysis on the output of the patching and apply additional fixes if something gets stuck? How about verifying that services work as expected after the patching and restoring them if not? Would you like to get an AI-enhanced automated summary of what was patched shipped to you via Discord/Telegram afterwards? These (and more) are but basic requirements that Patchmon in itself will not manage. That is why we can bring in n8n paired with a local (or external LLM) + Semaphore UI equipped with an arsenal of pre-configured jobs that will handle it for us.
Some of the limitations of Patchmon have been highlighted more than six months before this tutorial was published by Stephaneâs article called: âThe Perfect Patch Management Duo: PatchMon + Ansible AWXâ - worth a read! However, AWX has not received proper updates since Q4 2024 and the vision for it is to move to a paid closed system. For this reason, we will evaluate other options and set the jobs up in another automation platform.
In Part 1 we will stand Semaphore up from scratch in a dedicated Debian 13 LXC on a Proxmox node, backed by PostgreSQL, and set up in the UI. We will pair it up with a dynamic Proxmox inventory and set up new jobs (or migrate existing playbooks if you have any).
In Part 2, we will deploy Patchmon and add our hosts to see which ones need updates. Later in Part 4, we will set up threshold that will shoot a webhook when either standard OS updates or security updates are ready, which will act as the trigger for the whole workflow.
In Part 3, we will stand up Uptimekuma in LXC (or Docker), set up monitors and tag them by their hostnames, so that we can match them for uptime maintenance to disable false alarms during updates and reboots. The idea here is to automatically disable alarms when snapshotting and patching is in progress.
In Part 4, we set up two n8n workflows: one sub-workflow that handles repetitive tasks like starting a playbook, fetching results from Semaphore, setting a maintenance window in Uptimekuma, etc. Then we will connect it to the main orchestration workflow that will handle the whole process.
The main workflow handles exceptions for hosts that are not to be patched, followed by an UptimeKuma maintenance request, then fires a snapshot job if the host is a Proxmox virtual. After that, the actual patching starts - a suitable playbook is chosen based on the nature of the host - generic Debian hosts, web servers running nginx, MySQL hosts (Galera nodes), etc. This can be customized as per your needs.
The jobs are polled regularly using the sub-workflow and the result is posted on Discord/Telegram. The admin gets notified also if a reboot is required before it takes place. Each patching playbook also triggers a re-check from Patchmon to verify that all updates have been applied.
It is expected that you have at least the following when starting:
| Item | Purpose |
|---|---|
| Proxmox host (1 or more - can be in a cluster) | To patch VMs, LXC containers. |
| Gitea (or similar locally or cloud-hosted versioning system) | To store playbooks in |
| 2 GB of available RAM, 50 GB drive space (minimum) | Install all services either in one VM or as separate LXCs. |
In the past on my blog, I propagated the use of AWX. Yet there has not been an update / release since Q4 2024 and Red Hat now steers production users towards the (rather expensive) Ansible Automation Platform. I wanted something lighter, actively maintained and built to last.
What are the options? See a brief comparison evaluated during early July 2026:
| Criterion | Semaphore | Gitea Actions | Rundeck CE | AAP (dev sub) | AWX |
|---|---|---|---|---|---|
| Actively maintained / longevity | â steady | â (Gitea) | â open-core | â vendor | â stalled since Q4 2024 |
| Free & self-hosted | â | â | â (core) | â (individuals) | â |
| Lightweight | â Go binary | â runners | â ď¸ Java | â heavy | â k3s/6GB |
| Proxmox dynamic inventory + token | â via a plugin file | â you wire it | â ď¸ awkward | â | â |
| REST API to launch + read results | â clean | â ď¸ workflow dispatch | â | â | â |
| Scheduling (many jobs) | â (UI a bit plain) | â cron in YAML | â best-in-class | â | â |
| GitOps | â git templates | â â native | â ď¸ | â ď¸ | â ď¸ |
| RBAC/SAML | â ď¸ basic | â ď¸ repo-based | â | â | â |
The choice was set on Semaphore UI: a single Go binary that happily runs on a 1 GB box, gives you a clean web UI, a proper REST API, built-in scheduling, and speaks plain Ansible (plus Terraform, OpenTofu, PowerShell and scripts, if you ever fancy it).
In this first part we will stand Semaphore up from scratch in a dedicated Debian 13 LXC on my Site 2 Proxmox node, backed by PostgreSQL, and log into the UI. We will pair it up with a dynamic Proxmox inventory and set up new jobs (or migrate existing playbooks if you have any). Let's dive in!
On your Proxmox host, create a fresh container. You can do this in the Proxmox web UI (Create CT) or from the shell. The key choices:
Start the container and open its console (or SSH in) as root.
apt update && apt -y full-upgrade
# Create a user
adduser <username>
# Add the user into the list of admins
usermod -aG sudo jan
# Switch into that user
su jan
Semaphore itself is just one binary, but it needs Ansible to run playbooks, Git to clone your repos, and a database. While we are here we will also add the Python bits that the Proxmox dynamic inventory will need in Part 2, so we do not have to come back.
apt -y install ansible git curl wget gnupg ca-certificates \
python3-pip python3-venv python3-apt sshpass postgresql
# Python libraries for the community.proxmox inventory plugin (used in Part 2)
apt -y install python3-proxmoxer python3-requests
sshpass is only needed if any of your hosts authenticate with a password instead of an SSH key (harmless to have ready).ansible --version
psql --version
proxmoxer), we better check that it is at its newest version (at least 2.3.0 is required):sudo pip install --break-system-packages --upgrade 'proxmoxer>=2.3' requests
python3 -c "import proxmoxer; print(proxmoxer.__version__)" # should be ⼠2.3
Semaphore keeps your projects, encrypted credentials, schedules and task history in the database. Let's give it a dedicated database and user:
sudo -u postgres psql <<'SQL'
CREATE DATABASE semaphore;
CREATE USER semaphore WITH ENCRYPTED PASSWORD 'YourStrongPasswordHere';
ALTER DATABASE semaphore OWNER TO semaphore;
GRANT ALL PRIVILEGES ON DATABASE semaphore TO semaphore;
SQL
sudo -u postgres psql -d semaphore -c "GRANT ALL ON SCHEMA public TO semaphore;"
Semaphore publishes .deb packages on GitHub. This snippet detects the latest version and your architecture automatically, so you are not chasing version numbers:
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
The binary lands in /usr/bin/semaphore.
It is good practice not to run Semaphore as root. We give it its own system account and a home for its playbooks/working files:
sudo useradd --system --create-home --home-dir /opt/semaphore \
--shell /usr/sbin/nologin semaphore
sudo mkdir -p /opt/semaphore/config /opt/semaphore/playbooks
sudo chown -R semaphore:semaphore /opt/semaphore
For a package install, Semaphore generates its config.json through an interactive wizard. Run it as the semaphore user so the file ownership is correct:
sudo -u semaphore bash -c 'cd /opt/semaphore/config && semaphore setup'
Work through the prompts. The ones that matter:
When it finishes you will have /opt/semaphore/config/config.json.
Back this up now. That file contains access_key_encryption, the key that encrypts every credential in Semaphore's Key Store. Lose it and you lose access to all stored secrets. Copy it somewhere safe (and we will add it to our backup routine in Step 9).
Depending on the package version, the systemd unit may not be created for you. Create it explicitly so Semaphore starts on boot and restarts if it ever crashes:
cat >/etc/systemd/system/semaphore.service <<'EOF'
[Unit]
Description=Semaphore Ansible UI
Documentation=https://docs.semaphoreui.com
Wants=network-online.target
After=network-online.target postgresql.service
[Service]
Type=simple
ExecStart=/usr/bin/semaphore server --config /opt/semaphore/config/config.json
ExecReload=/bin/kill -HUP $MAINPID
SyslogIdentifier=semaphore
Restart=always
User=semaphore
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now semaphore
systemctl status semaphore --no-pager
You should see active (running). If not, journalctl -u semaphore -e will tell you why - nine times out of ten it is the database password or the PG public schema grant from Step 3.
Open a browser on your LAN to reach the hostâs IP on port 3000. Sign in with the admin user you created:
A whirlwind tour of the concepts you will use:
The plan:
collections/requirements.yml and an ansible.cfg so the Proxmox plugin loads cleanly).ansible that can reach all hosts inc. each individual Proxmox host. Add it to Semaphore.inventory/site_name folders.Create a new repository for running Semaphore playbooks.
For those who ran AWX previously and would like to use the same repo with the same Ansible playbooks: please do not, as the syntax for some Ansible playbooks is different. For example, to store credentials for reaching Proxmox host(s), we will be using Ansible Vault and will modify the playbooks, accordingly.
Your playbooks already live in Gitea (or a similar app). We just need to add two small files into your existing Gitea repo so Semaphore can resolve the Proxmox plugin without surprises.
a) Tell Semaphore which collections to install. Semaphore automatically runs ansible-galaxy against a requirements.yml in your repo. Create a file under the root folder of your repo under collections/requirements.yml:
---
collections:
- name: community.proxmox
- name: community.general
proxmoxer and requests ) we already installed into the LXC in the previous Step, so the plugin has what it needs.b) Add an ansible.cfg file at the repo root so the inventory plugin is enabled and first-contact host-key prompts don't stall an unattended run:
[defaults]
host_key_checking = False
interpreter_python = auto_silent
collections_path = ./collections
[inventory]
enable_plugins = community.proxmox.proxmox, auto, host_list, yaml, ini
One naming detail that matters: the Proxmox inventory plugin only auto-loads from files ending with proxmox.yml / proxmox.yaml , so ensure that yours does, too (such as biohazard-proxmox.yml).
c) Add a deliberately harmless check playbook for our first run (ansible.builtin.ping makes no changes, it just confirms Python + SSH work end to end). Create a file under checks/ping.yml:
---
- name: Read-only connectivity check
hosts: all
gather_facts: true
ignore_unreachable: true
tasks:
- name: Ping every reachable host
ansible.builtin.ping:
- name: Show who we reached
ansible.builtin.debug:
msg: "{{ inventory_hostname }} ({{ ansible_host }}) reachable"
A deploy key is a per-repository SSH key, which is ideal here because it's read-only and scoped to just this repo, so Semaphore can pull but never push or touch anything else.
Generate a dedicated keypair (no passphrase, so the service can use it unattended). You can do this right on the Semaphore LXC:
ssh-keygen -t ed25519 -C "semaphore-deploy" -f ~/semaphore_gitea_deploy -N ""
cat ~/semaphore_gitea_deploy.pub   # the PUBLIC key â goes into Gitea
cat ~/semaphore_gitea_deploy     # the PRIVATE key â goes into Semaphore
semaphore user's known_hosts:sudo -u semaphore mkdir -p /opt/semaphore/.ssh && sudo chmod 700 /opt/semaphore/.ssh
ssh-keyscan -H git.bachelor-tech.com | sudo -u semaphore tee -a /opt/semaphore/.ssh/known_hosts
sudo chown -R semaphore:semaphore /opt/semaphore/.ssh
shred -u ~/semaphore_gitea_deploy ~/semaphore_gitea_deploy.pub
If you haven't already, create a Project (e.g. Homelab), everything below lives inside it. Go to Repositories â New Repository:
semaphore-playbooksgitea-deploy (the key from Step 2)Save. Semaphore will validate it can reach the repo.
At this point, we have Semaphore connected with Gitea with a requirements set with Proxmoxer in place, but we have no hosts to run the harmless âpingâ playbook against.
This is the most boring but necessary part - to ensure Ansible can reach each host, set up a single password-less key that you distribute to each host.
# 1. Create the key. Use -N "" for no passphrase (AWX needs this)
ssh-keygen -t ed25519 -f $HOME/.ssh/awx_key -N ""
# 2. Display the PUBLIC key. Copy this entire line to your clipboard.
cat $HOME/.ssh/awx_key.pub
# 1. Create the 'ansible' user with no password.
# -m creates the /home/ansible directory.
# -s /bin/bash sets their shell.
sudo useradd -m -s /bin/bash ansible
# Lock the user - disable password-based login
sudo passwd -l ansible
# 2. Give the user passwordless sudo
sudo visudo
# Add this line at the very end of the file. Save and exit.
ansible ALL=(ALL) NOPASSWD: ALL
# 3. Create the .ssh directory and file as the 'ansible' user
sudo -u ansible mkdir /home/ansible/.ssh
sudo -u ansible chmod 700 /home/ansible/.ssh
sudo -u ansible touch /home/ansible/.ssh/authorized_keys
sudo -u ansible chmod 600 /home/ansible/.ssh/authorized_keys
# 4. Open the file and paste your key
sudo -u ansible nano /home/ansible/.ssh/authorized_keys
ssh-ed25519 your-public-key
# Paste the key from the clipboard. Save & exit.
sudo visudo command does not work on Linux:sudo apt install sudo -y # Debian/Ubuntu
For hosts that are not Linux-based, such as FreeBSD (on OPNSense, select option 8 to get to Shell first):
pkg install sudo
adduser
- Username: ansible
- Full name: Ansible Service User
- Uid (Leave empty for default): (Press Enter)
- Login group [ansible]: **wheel**
- Invite ansible into other groups? []: (Press Enter)
- Login class [default]: (Press Enter)
- Shell [sh]: (Press Enter)
- Home directory [/home/ansible]: (Press Enter)
- Home directory permissions (Leave empty **for** default): (Press Enter)
- Use password-based authentication? [yes]: **no** (This will disable password login)
- Lock out the account after creation? [no]: (Press Enter)
- OK? [yes/no]: yes
- Add another user? (yes/no) [no]: no
visudo
# Here add the row. Most likely, you will be using 'vi' - press 'a' for insert mode
# Find the row, insert the text:
ansible ALL=(ALL) NOPASSWD: ALL
# Then press Escape, followed by typing :wq
# followed by enter. To exit without saving, type :q!
# Set up folder/file permissions
mkdir /home/ansible/.ssh
chmod 700 /home/ansible/.ssh
touch /home/ansible/.ssh/authorized_keys
chmod 600 /home/ansible/.ssh/authorized_keys
chown -R ansible:wheel /home/ansible/.ssh
ee /home/ansible/.ssh/authorized_keys # or use vi
# Paste the public part of the key, save and exit.
This is a different key from the Gitea one, it is the private key for the ansible user that your hosts already trust that we set up earlier. Import it into Key Store â New Key, Type SSH Key, name it something like ansible_all_hosts, and paste the private key (plus passphrase if it has one).
So far we have Semaphore installed and running as a service, connected to our Gitea repo for playbooks, and a password-less ansible SSH key trusted on every host we want to manage. What is still missing is the Proxmox side of things: Semaphore needs a way to discover each guest's current IP address, and it needs API credentials so it can eventually take snapshots and query VM/LXC state directly through the Proxmox API rather than only over SSH. This step covers both: installing the ip2tag helper on each Proxmox host so IPs show up as tags we can inventory, and creating the Proxmox API tokens that Semaphore will use.
In order to get up-to-date IP addresses for each host in the Semaphore inventory, we will need to use a nifty little app that shares the IP address as a tag, which we will then import into Semaphore as a group.
INSTALL_SOURCE=github bash -c "$(curl -sSL https://github.com/MorsStefan/proxmox-ip2tag/releases/latest/download/prox-ip2tag_install.sh)"
192.168.5.0/24, then you would configure it as follows:nano /usr/local/etc/prox-ip2tag.conf
GNU nano 8.4 /usr/local/etc/prox-ip2tag.conf
#!/bin/bash
# Defines which guest types to process: 1 = LXCs, 2 = VMs, 3 = both.
GUEST_TYPE=3
# IP addresses and network ranges in CIDR format in which IP must be located
# in order to be automatically added or removed as IP tag.
# Well known private networks: 10.0.0.0/24 192.168.0.0/24
# Use 0.0.0.0/0 to search for every possible IP'a
NETWORK_RANGES=(
192.168.5.0/24
)
# Do not auto add or remove these IP tags for VMs and LXCs, do not change their color.
# Accepts both: single addresses and network ranges in CIDR format
NETWORK_RANGES_IGNORED=(
127.0.0.1
)
systemctl restart prox-ip2tag.service
systemctl status prox-ip2tag.service
web, nginx, debian, 22, site_1 .database, galera, debian, cluster_a, 2222, site_1 nginx but also web servers running apache2 or another service.Whether you run a cluster or an individual host, an API key in Proxmox is created on the âDatacenterâ level. So for a cluster, you create one key in that section and it applies to all your hosts in that cluster. Standalone hosts have one API key each.
ansible (this will become ansible@pam).ansible@pam user from the drop-down.semaphore_tokenansible@pam!semaphore_tokenPVEAdminBitwarden or the self-hosted variant called Vaultwarden).Rinse and repeat for each Proxmox cluster/host.
Now since we have Semaphore configured to reach our repo in Gitea AND we can reach each Proxmox host (with its tags being set up earlier that can pass over as groups), we can eventually get the inventory imported into Semaphore - how exciting!
If you just have one Proxmox cluster or host, you could store the token_id and token_secret as an environment secret. However, the moment you have more than one Proxmox host/cluster (which is likely most home labbers), this approach would not work, as the dynamic inventory plugin accepts only one variable called PROXMOX_TOKEN_SECRET (only one such variable can be supplied during a task run).
Technically, you could have templates that do the same job just calling different sites as needed. This could work, unless you have automation in place that calls a particular host without knowing its location. For example, I have a template called galera-rejoin , when Uptimekuma triggers an n8n workflow that triggers a playbook that helps an orphaned MariaDB node to rejoin its cluster. In such a case, there is no easy way to know which site (or Proxmox cluster) is the particular Galera instance on. But do not worry, there is a workaround in place that works well.
The answer to the challenge is to utilize Ansible Vaults. This might be a new topic for you. Although it is out of the scope of a Semaphore set up tutorial, since it is crucial for the dynamic inventory to work, I will expand on it below and we will set it up together.
Part 1 - Bind the secret to the inventory instead of the environment, using Ansible Vault per-site tokens:
Proxmox tokensâ .Login with password. Create some password that will be used later. This is how Semaphore will authenticate against Ansible Vault to retrieve it during a playbookâs runtime.# The 'actual-secret' is the Proxmox token's secret. Keep the 'token_secret' as-is:
ansible-vault encrypt_string 'actual_secret' --name 'token_secret'
ansible-vault encrypt_string does not generate a hash. It generates encrypted text (a ciphertext) using AES-256 symmetric encryption, meaning the original string can be completely decrypted back to its plaintext form if you have the correct password (which we created just above). Save it somewhere in your password manager, such as in Bitwarden/1Password.| Item Type | What it is | Where it lives |
|---|---|---|
| Key Store entry | Holds the vault password (not the Proxmox token) | Semaphore (UI) |
| Proxmox token secret | The real credential to Proxmox (e.g. ecf6ce36-d22f-âŚ) |
Encrypted into the inventory file |
| Vault password | A passphrase you invent that locks/unlocks the above | Semaphore (Key Store) |
Part 2 - Create a single inventory path for more inventories (dynamic and static):
In your Gitea repo, put all inventory files under a folder such as inventory/ and point to that one. In other words, the tree structure in your Gitea repo would look like something like this:
jan@semaphore:~/semaphore-playbooks/inventory$ tree
.
|-- site1-uvody
| |-- uvody-dynamic-proxmox.yml
| `-- uvody-static-proxmox.yml
`-- site2-tusarka
|-- tusarka-dynamic-proxmox.yml
`-- tusarka-static-proxmox.yml
The content of each dynamic inventory file can look like this - this is the content of the /semaphore-playbooks/inventory/site1-uvody/dynamic-proxmox.yml file.
---
# U VODY Site 1 cluster dynamic inventory file
# Below are details about our Proxmox cluster or instance + how to reach it + groups logic:
url: https://1.2.3.4:8006
validate_certs: false
user: ansible@pam
token_id: semaphore_token
# The secret below is fetched from Ansible vault saved on the Semaphore instance.
token_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
39313735393833393361316530393961383612128396265393161343236383236646232326526363
3531613337633638333366356430616633666633656137340a613737396264353530346536343135
38636265346539353231393636633632643235336363383030366561323165653765393138663531
6265643330633839650a656334663261613532343834356165613730353761336338356564626364
31643564636339363462333536613137613538613339323361613635386135333263363330343639
356139333165313430343335343464396663306165373361212
# Specify which tool (plugin) we will need:
plugin: community.proxmox.proxmox
want_facts: true
qemu_extended_statuses: true
# Group the fetched hosts based on their tags in Proxmox to these categories.
# Modify the it as per your needs. In this case, the 'web' tag is translated
# into a group called webservers.
groups:
webservers: "'web' in (proxmox_tags_parsed|list)"
mailservers: "'mail' in (proxmox_tags_parsed|list)"
databases: "'database' in (proxmox_tags_parsed|list)"
gaming: "'gaming' in (proxmox_tags_parsed|list)"
network: "'network' in (proxmox_tags_parsed|list)"
productivity: "'productivity' in (proxmox_tags_parsed|list)"
cctv: "'cctv' in (proxmox_tags_parsed|list)"
monitoring: "'monitoring' in (proxmox_tags_parsed|list)"
debian_hosts: "'debian' in (proxmox_tags_parsed|list)"
opnsense_hosts: "'opnsense' in (proxmox_tags_parsed|list)"
ubuntu_hosts: "'ubuntu' in (proxmox_tags_parsed|list)"
nginx: "'nginx' in (proxmox_tags_parsed|list)"
nodejs: "'nodejs' in (proxmox_tags_parsed|list)"
windows: "'windows' in (proxmox_tags_parsed|list)"
galera: "'galera' in (proxmox_tags_parsed|list)"
uvody: "'uvody' in (proxmox_tags_parsed|list)"
tusarka: "'tusarka' in (proxmox_tags_parsed|list)"
site3: "'site3' in (proxmox_tags_parsed|list)"
site4: "'site4' in (proxmox_tags_parsed|list)"
docker_host: "'docker' in (proxmox_tags_parsed|list)"
cluster_a: "'cluster_a' in (proxmox_tags_parsed|list)"
cluster_b: "'cluster_b' in (proxmox_tags_parsed|list)"
cluster_c: "'cluster_c' in (proxmox_tags_parsed|list)"
# Applies to all Proxmox VMs and LXCs for snapshots to distinguish them from bare-metal hosts
all_proxmox_guests: "proxmox_vmid is defined"
# Skip the host if it does not have an VM ID (works for LXCs as well) OR if it is shut down
# Modify as you need - such as if you want to fetch hosts that are shut down.
filters:
- proxmox_vmid is defined
- proxmox_status != "stopped"
# Additional settings - tag for virtuals that use port 2222 for SSH + fetch network details +
# modify the default description field in the 'Hosts' tab
# Note if the 'Description' field is already filled in AWX, you will need to flush away your hosts and re-run the job.
compose:
ansible_port: "2222 if '2222' in (proxmox_tags_parsed|list) else 22"
ansible_user: "'ansible'"
ansible_host: "(proxmox_name + '.your.doman.tld') if proxmox_vmtype == 'qemu' else (proxmox_hostname + '.your.doman.tld')"
ansible_ip: "(proxmox_tags_parsed | list | first)"
---
all:
children:
# Define a group for your hypervisors + additional non-Proxmox hosts
proxmox_nodes:
hosts:
proxmox1:
ansible_host: "proxmox1.mydomain.tld"
ansible_port: "2222"
ansible_ip: "192.168.5.3"
ansible_user: "ansible"
ansible_python_interpreter: /usr/bin/python3
proxmox2:
ansible_host: "proxmox2.mydomain.tld"
ansible_port: "2222"
ansible_ip: "192.168.5.4"
ansible_user: "ansible"
ansible_python_interpreter: /usr/bin/python3
pbs:
ansible_host: "pbs.mydomain.tld"
ansible_port: "22"
ansible_ip: "192.168.5.16"
ansible_user: "ansible"
ansible_python_interpreter: /usr/bin/python3
# Map them to the Debian group
debian_hosts:
hosts:
proxmox1:
proxmox2:
pbs:
# Map them to the site group
uvody:
hosts:
proxmox1:
proxmox2:
pbs:
Exciting times - now we have all the required pieces:
ansible_ipfallback variable (in case the hostname does not resolve).All we need to do now is to test that we can reach the hosts! Go to Task Templates â New Template â New Ansible Playbook:
checks/ping.yml (relative path within the repository)Save and hit run. Watch the live task log. A healthy run will:
community.proxmox collection.đĄ Note
Note that hosts that fail to ping will still be reported as success - you will see them at the end as âignored=2â. In my case, I manage OPNSense hosts manually and so I do not have the SSH key installed on them, so while Semaphore can see them due to the dynamic inventory plugin, it cannot reach them.
-----BEGIN/END OPENSSH PRIVATE KEY----- lines . Edit it and re-paste the full private key including those header/footer lines and a trailing newline.[ERROR]: Failed to parse the requirements.yml at '/opt/semaphore/playbooks/project_1/repository_1_template_1/collections/requirements.yml' . Check the syntax of your .yml file, including that it starts with three dashes and correct spacing.{{ proxmox_ip | default(ansible_ip) }}" logic.PROXMOX_TOKEN_SECRET value in the Proxmox site environment.Failed to parse inventory with 'ansible_collections.community.proxmox.plugins.inventory.proxmox' plugin: 401 Client Error: Authentication failed!. This means that your Proxmox token is incorrect.It would be good to distinguish here between the static/dynamic inventory and group vars (variables).
A dynamic inventory file (such as site1-dynamic-proxmox.yml) is config for the inventory plugin itself. Semaphore hands this file to community.proxmox.proxmox before anything else runs, so it can call the Proxmox API, discover which VMs/LXCs exist, sort them into groups by tag, and set the connection vars (ansible_host, ansible_port, ansible_user) needed to SSH into each guest. Its url/token_id/token_secret are plugin parameters, they exist only to let Ansible build the inventory. They are not exposed to your playbooks as variables afterwards.
On the other hand, a group_vars file (inventory/group_vars/somefile.yml) is regular Ansible variable data, auto-loaded for any host in the site group (the group your dynamic inventory created from the site tag, such as uvody). They're consumed by your playbook tasks, at run time, whenever a task needs to talk to the Proxmox API directly rather than SSH into the guest.
You could just have vars:in each of your playbook to provide instructions on how to reach each Proxmox host. This would, however, be tiring with duplicate information - not the best practice for more than one playbook/one Proxmox host/cluster.
In your repo, create a folder called âgroup_varsâ under the âinventoryâ folder name that starts with the same name as your site. In my case, I have a cluster per site, so it looks like this:
jan@semaphore:~/semaphore-playbooks/inventory$ tree
.
|-- group_vars
| |-- tusarka.yml
| `-- uvody.yml
|-- site1-uvody
| |-- uvody-dynamic-proxmox.yml
| `-- uvody-static-proxmox.yml
`-- site2-tusarka
|-- tusarka-dynamic-proxmox.yml
`-- tusarka-static-proxmox.yml
cluster1, you will need a group called with that name as well.# Alternative structure (1 site, 2 clusters)
jan@semaphore:~/inventory$ tree
.
|-- cluster1
| `-- cluster1-proxmox.yml
|-- cluster2
| `-- cluster2-proxmox.yml
`-- group_vars
|-- cluster1.yml
`-- cluster2.yml
/inventory/group_vars, populate it as follows:---
# Site 1 variables
proxmox_api_host: "1.2.3.4"
proxmox_api_port: 8006
proxmox_api_user: "ansible@pam"
proxmox_api_token_id: "semaphore_token"
proxmox_validate_certs: false
proxmox_api_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
393137...long numeric string...
đĄ Note
If you have some parameters that apply to all group_vars, you can set up a file called
all.ymland place it there for cluster-wide defaults.
You will notice that your hosts are not visible in Semaphore anywhere like with AWX. Semaphore renders the inventory at EVERY template run. This is by design.
What if you want to see what is in your inventory before you execute a task? Such as to confirm that the group membership is correct. For this reason, there is a purpose-built tool called ansible-inventory. It is already installed, we just need to save the ansible SSH key to Semaphoreâs shell and add the community.proxmox / community.generala collections so that our shellâs Ansible can talk to Proxmox (basically similar to what we already configured before for Semaphore UIâs app in Gitea).
# Upload the private part of your ansible key
nano /home/jan/.ssh/ansible_key
# Change the permissions to the owner only
chmod 600 /home/jan/.ssh/ansible_key
# Install the collection for your user
ansible-galaxy collection install community.proxmox community.general
# Get a copy of the repo to run against (if you don't have one locally)
git clone https://git.your-server.tld/path/to/ansible-playbooks-repo ~/semaphore-playbooks
# git clone https://git.bachelor-tech.com/jan/ansible-playbooks ~/ansible-playbooks
cd ~/semaphore-playbooks
# Run the command with @ (all) or specific ones, such as @nginx
ansible-inventory -i inventory --graph --ask-vault-pass | grep -A20 '@'
jan@semaphore:~/semaphore-playbooks$ ansible-inventory -i inventory --graph --ask-vault-pass | grep -A20 '@'
Vault password:
@all:
|--@ungrouped:
|--@proxmox_nodes:
| |--proxmox2
| |--proxmox1
| |--proxmox3
|--@debian_hosts:
| |--galera-A3
| |--gitea-turnkey
| |--uptimekuma
| |--galera-A4
| |--semaphore
| |--mail1
| |--awx-ansible
| |--web1
| |--honza-web1
| |--proxmox1
| |--proxmox2
| |--pbs
| |--galera-A5
| |--galera-A6
| |--uptimekuma2
| |--docker-metrics
| |--web3
| |--proxmox3
|--@gaming:
| |--win-minecraft
| |--ubu-minecraft1
...
đĄ Note
If you run a command from the shell later on, always do a 'git pull' to download updates from Gitea.
The pre-requisite is that your Proxmox hosts already have a password-less SSH key that has sudo privileges. This is because certain activities require sudo privileges and the patching playbook uses a become: true flag to be executed with root privileges.
Create a new file in your Gitea repository under helpers/proxmox_snapshot_host.yml :
---
- name: Create a Proxmox Snapshot
hosts: all_proxmox_guests
become: false
connection: local
serial: 1
gather_facts: false
# no vars: block, all values come from group_vars
tasks:
- name: Create new snapshot (with RAM)
community.proxmox.proxmox_snap:
api_host: "{{ proxmox_api_host }}"
api_port: "{{ proxmox_api_port }}"
api_user: "{{ proxmox_api_user }}"
api_token_id: "{{ proxmox_api_token_id }}"
api_token_secret: "{{ proxmox_api_secret }}"
validate_certs: "{{ proxmox_validate_certs | default(false) }}"
vmid: "{{ proxmox_vmid }}"
snapname: "Semaphore_Patch_Backup_{{ lookup('pipe', 'TZ=Europe/Prague date +%Y-%m-%d_%H-%M') }}"
description: "Semaphore Task ID: {{ semaphore_vars.task_details.id | default('Manual Run', true) }}"
vmstate: true
state: present
retention: 3
timeout: 300
đĄ Note
If you have more admins that run jobs, you can grab other handy fields the same way, such as
semaphore_vars.task_details.usernameto indicate who launched it. See more here.
helpers/proxmox_snapshot_host.yml(your case will likely vary)ALL Sites & ClustersJan's Semaphore Playbooks Gitea Repo uptimekuma In your Gitea repository, create a new file called patch_debian_single_hosts.yml (unless you have it already) that will provide patching of a single Debian host. What does it do?
whoami) to confirm connectivity.---
- name: Patch Debian-based Systems
hosts: debian_hosts
become: true # Elevate permissions (execute with sudo)
serial: 1 # Run one host a time
tasks:
- name: Update apt repo and cache
ansible.builtin.apt:
update_cache: yes
force_apt_get: yes
cache_valid_time: 3600
- name: Upgrade all apt packages
ansible.builtin.apt:
upgrade: dist
autoremove: yes
autoclean: yes
- name: Check if a reboot is required (kernel/libs)
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required_file
- name: Check if uptime is greater than 90 days
ansible.builtin.assert:
that: (ansible_uptime_seconds | int) < 7776000 # (90*24*60*60)
fail_msg: "Uptime ({{ (ansible_uptime_seconds / 86400) | round(1) }} days) is over 90 days. Forcing reboot."
quiet: true
# This task will "fail" if uptime is > 90 days
# We use 'ignore_errors: true' so the playbook continues
register: uptime_check
ignore_errors: true
- name: Reboot the server if (kernel needs it) OR (uptime > 90 days)
ansible.builtin.reboot:
msg: "Rebooting server after Ansible patch run"
connect_timeout: 5
reboot_timeout: 300
post_reboot_delay: 30
test_command: whoami
when: reboot_required_file.stat.exists or uptime_check.failed
Patch Debian Hostspatch_debian_single_hosts.yml(specify folder before if not in the root folder of your repo)cache and dist upgrades were executed, no reboot was required:The playbook below could almost be fully carried over from AWX to Semaphore, apart from one change - notice the âtag_â line:
- name: Safely patch Nginx Web Servers (Rolling Update)
# This targets the dynamic group created by your 'nginx' tag
hosts: tag_nginx
become: true # Elevate to sudo
# Run on one host a time
serial: 1
...
tag_<name> groups (via keyed_groups). On the other hand, our Semaphore inventory builds groups from the groups: block instead, which produces plain names (nginx, galera, debian_hosts) - no tag_ or all_ prefix. So the hosts: lines point at groups that don't exist in Semaphore and will match nothing.---
- name: Safely patch Nginx Web Servers (Rolling Update)
hosts: nginx
become: true
serial: 1
tasks:
- name: Update apt cache (capture repo errors clearly)
block:
- name: Update apt repo and cache
ansible.builtin.apt:
update_cache: yes
force_apt_get: yes
cache_valid_time: 3600
rescue:
- name: Re-run apt-get update to capture the real error
ansible.builtin.command: apt-get update
register: apt_update_raw
changed_when: false
failed_when: false
- name: Fail with the actual repo error
ansible.builtin.fail:
msg: |
apt cache update FAILED on {{ inventory_hostname }}, likely a repo/GPG issue:
--- stdout ---
{{ apt_update_raw.stdout }}
--- stderr ---
{{ apt_update_raw.stderr }}
- name: Upgrade all apt packages
ansible.builtin.apt:
upgrade: dist
autoremove: yes
autoclean: yes
- name: Check if a reboot is required (kernel/libs)
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required_file
- name: Check if uptime is greater than 90 days
ansible.builtin.assert:
that: (ansible_uptime_seconds | int) < 7776000
fail_msg: "Uptime over 90 days. Forcing reboot."
quiet: true
register: uptime_check
ignore_errors: true
- name: Reboot if kernel needs it OR uptime > 90 days
ansible.builtin.reboot:
msg: "Rebooting server after Ansible patch run"
connect_timeout: 5
reboot_timeout: 300
post_reboot_delay: 30
test_command: whoami
when: reboot_required_file.stat.exists or uptime_check.failed
- name: Wait for Nginx to be serving traffic (HTTP 200)
ansible.builtin.uri:
url: http://localhost
status_code: 200
register: nginx_status
until: nginx_status.status == 200
retries: 20
delay: 15
In case you have more MariaDB instances joined in a Galera cluster, you may wish to not only patch the OS but also ensure that the node rejoins the cluster successfully afterwards. You can use this one:
serial: 1parameter ensures that only one host is processed at a time.Become: true is required to get sudo privileges---
- name: Safely patch Galera Cluster (one node at a time)
hosts: galera # Confirm by running: ansible-inventory -i inventory --graph --ask-vault-pass | grep -A20 '@'
become: true
serial: 1
tasks:
- name: Update apt repo and cache
ansible.builtin.apt:
update_cache: yes
force_apt_get: yes
cache_valid_time: 3600
- name: Upgrade all apt packages
ansible.builtin.apt:
upgrade: dist
autoremove: yes
autoclean: yes
- name: Check if a reboot is required (kernel/libs)
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required_file
- name: Check if uptime is greater than 90 days
ansible.builtin.assert:
that: (ansible_uptime_seconds | int) < 7776000
fail_msg: "Uptime over 90 days. Forcing reboot."
quiet: true
register: uptime_check
ignore_errors: true
- name: Reboot if kernel needs it OR uptime > 90 days
ansible.builtin.reboot:
msg: "Rebooting server after Ansible patch run"
connect_timeout: 5
reboot_timeout: 300
pre_reboot_delay: 0
post_reboot_delay: 30
test_command: whoami
when: reboot_required_file.stat.exists or uptime_check.failed
- name: Wait for the MariaDB port (3306) to be open
ansible.builtin.wait_for:
host: "{{ ansible_host }}"
port: 3306
delay: 15
timeout: 600
state: started
- name: Wait until this node has rejoined the cluster (wsrep Synced)
ansible.builtin.shell: >
mysql -N -B -e "SHOW STATUS LIKE 'wsrep_local_state_comment';"
register: wsrep_state
changed_when: false
until: "'Synced' in wsrep_state.stdout"
retries: 30 # 30 times 10s = up to 5 minutes
delay: 10
- name: Show cluster size for visibility
ansible.builtin.shell: >
mysql -N -B -e "SHOW STATUS LIKE 'wsrep_cluster_size';"
register: wsrep_size
changed_when: false
- name: Report node health
ansible.builtin.debug:
msg: "{{ inventory_hostname }} â {{ wsrep_state.stdout.split('\t')[1] }} (cluster size {{ wsrep_size.stdout.split('\t')[1] }})"
Overall, I have transferred most of the templates created for AWX and it was straightforward. If you are interested in more details in any of those, leave a comment below.
Feel free to do more testing, esp. if you have different Proxmox nodes.