# Part 4 - Create an n8n workflow for patching with Patchmon and Semaphore UI [TOC] In the previous parts of this series, we did the following: - **In Part 1**, we set up Semaphore with useful playbooks for snapshotting hosts on Proxmox before patching and playbooks that patch hosts differently based on their groups (such as web and DB servers). - **In Part 2**, we set up Patchmon, explained why it is not yet ready for patching despite its recently added capabilities and connected it to our fleet of hosts, whether on Proxmox or other. We pushed a Semaphore job that deployed the Patchmon agent on all standalone hosts and VMs. - **In Part 3**, we stood up Uptime Kuma and tagged every monitor with the hostname it belongs to, so we can put a host's monitors into a maintenance window for the duration of a patch run, no false "down" alert from a reboot, or from the brief pause a Proxmox snapshot causes. Now we are getting into the interesting part by connecting them together with n8n! While some may argue that β€˜n8n is dead’ due to recently identified security vulnerabilities back in early 2026 and before, I would argue that if we can work around the resulting limitations (such as jobs being launched in isolated containers), we actually have a more robust, time-proven workflow engine with an okay UI. So let’s dive in! ## Step 1 - Deploy n8n In case you do not have n8n installed already, here is a little guide on how to deploy it in a docker environment together with a containerized PostgreSQL instance to keep our data persistent and more production-ready than when using a simple SQLite (you can otherwise skip to Step 2). - Create a folder on the VM for n8n and set up the docker-compose file: ```yaml # Create directory and a docker-compose file sudo mkdir -p /opt/n8n cd /opt/n8n sudo nano docker-compose.yml ``` ```yaml services: postgres: image: postgres:16.6-alpine container_name: n8n-postgres restart: unless-stopped environment: - POSTGRES_DB=n8n - POSTGRES_USER=n8n - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} volumes: - n8n_postgres_data:/var/lib/postgresql/data networks: - n8n-network healthcheck: test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"] interval: 10s timeout: 5s retries: 5 start_period: 10s # No ports published to the host on purpose - only n8n on this network needs it. mem_limit: 512m cpus: 1.0 n8n: image: docker.n8n.io/n8nio/n8n:2.30.3 container_name: n8n restart: unless-stopped depends_on: postgres: condition: service_healthy ports: - "5678:5678" environment: # --- General --- - GENERIC_TIMEZONE=Europe/Prague # Set up your timezone - TZ=Europe/Prague # Set up your timezone - NODE_ENV=production # --- Database (Postgres, standalone, colocated with this container) --- - DB_TYPE=postgresdb - DB_POSTGRESDB_HOST=postgres - DB_POSTGRESDB_PORT=5432 - DB_POSTGRESDB_DATABASE=n8n - DB_POSTGRESDB_USER=n8n - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD} # --- Credential encryption --- # Set explicitly and back this up OUTSIDE the n8n_data volume. # If this key is lost, every stored credential becomes unrecoverable. - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} # --- Networking --- # NOTE: We will set up TLS later - N8N_HOST=n8n.bakalar.priv - N8N_PORT=5678 - N8N_PROTOCOL=http - WEBHOOK_URL=http://1.2.3.4:5678/ - N8N_SECURE_COOKIE=false # --- Code node permissions --- # Loosened from defaults - our workflow does not require fs - NODE_FUNCTION_ALLOW_BUILTIN=fs,path - N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true - N8N_RESTRICT_ENVIRONMENT_VARIABLES_ACCESS=true # --- Task runners --- - N8N_RUNNERS_ENABLED=true - N8N_RUNNERS_MAX_OLD_SPACE_SIZE=1024 - N8N_RUNNERS_TASK_TIMEOUT=600 volumes: - n8n_data:/home/node/.n8n - ./local-files:/files networks: - n8n-network healthcheck: test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 30s mem_limit: 1g cpus: 1.5 volumes: n8n_data: name: n8n_data n8n_postgres_data: name: n8n_postgres_data networks: n8n-network: name: n8n-network ``` - Stand up a .env file - here is an example: ```bash nano /opt/n8n/.env # Copy this to .env (same directory as docker-compose.yml) and fill in real # values. Do NOT commit .env to git - add it to .gitignore. # Postgres password for the n8n database user. POSTGRES_PASSWORD=change-me-to-a-strong-random-value # n8n credential encryption key. Generate one with: # openssl rand -base64 32 # Back this value up somewhere OTHER than the n8n_data volume (password # manager, vault, encrypted note). Losing it makes every stored credential # permanently unreadable, even if the volume itself is intact. N8N_ENCRYPTION_KEY=change-me-generate-with-openssl ``` > πŸ’‘ **Note** > > If you are currently on an n8n set up with SQLite and want to upgrade it to PostgreSQL, you can export your current settings (workflows + credentials) to import them to the new DB (providing you have a path such as `./local-files/files` mounted): > ```bash > docker exec n8n n8n export:workflow --all --output=/files/workflows.json > docker exec n8n n8n export:credentials --all --output=/files/credentials.json > docker compose down > # Extract the key from the SQLite instance and replace the 'N8N_ENCRYPTION_KEY' value with it in .env: > docker run --rm -v n8n_data:/data alpine sh -c "apk add --no-cache jq >/dev/null 2>&1; jq -r .encryptionKey /data/config" > ``` > - Then, once you have launched the PostgreSQL + n8n instance, import them: > ```bash > docker exec n8n n8n import:workflow --input=/files/workflows.json > docker exec n8n n8n import:credentials --input=/files/credentials.json > ``` ```yaml # Deploy docker compose pull docker compose up -d # Check status docker compose logs -f ``` - Try accessing it on the website:

1 step 1 deploy n8n

- Complete your registration to receive a free license key (indicate that you are not using n8n for work purposes). ## Step 2 - Logic of the patching workflow This is the most brainy part of the whole series - we will have two n8n workflows. The diagram below joins them together to showcase the entire workflow logic. The trigger is Patchmon, our source of truth regarding updates. If updates are found, an n8n workflow is triggered (see in Step 5), which resolves the host and acquires a per-group lock, optionally opens an Uptime Kuma maintenance window so the coming reboot or snapshot pause doesn't fire a false alert, then calls for a snapshot (if a Proxmox guest) by running a sub-workflow (Step 3 below). Once a snapshot is taken, the main workflow kicks in by triggering the relevant Semaphore job template for the host's group. Yes, there is not just one template for patching; we need to take the nature of the host group into account (we configured them in Part 1 of this series). For example, with a web server running nginx, we will want to ensure that nginx is running after patching is complete. Here is a diagram of the whole workflow logic (thanks, Claude, for putting my ideas together here): ```bash β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 0. TRIGGER β”‚ β”‚ PatchMon webhook β†’ n8n β”‚ β”‚ payload: { hostname, event_type, severity, β”‚ β”‚ host_id, pending_count, threshold } β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Hostname on the β”‚ β”‚ EXCLUDED_HOSTNAMES β”‚ β”‚ list? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ yes β”‚ β”‚ no β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ SKIP - patch by hand β”‚ β”‚ β”‚ (e.g. Proxmox hosts) β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 1. RESOLVE HOST β”‚ β”‚ Semaphore: run patchmon_lookup.yml, Limit=hostname β”‚ β”‚ poll task β†’ parse output β”‚ β”‚ returns: groups[], is_proxmox_guest, proxmox node/type/vmid, β”‚ β”‚ in_alert_cooldown β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Already in a 24h β”‚ β”‚ alert cooldown? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ yes β”‚ β”‚ no β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ SKIP - already β”‚ β”‚ β”‚ alerted, unresolved β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 2. DECIDE β”‚ β”‚ n8n: match groups[] against groupβ†’template rule table β”‚ β”‚ β†’ chosen Semaphore patch template β”‚ β”‚ β†’ lock_key (shared by hosts in the same group, e.g. Galera) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Host is security-only β”‚ β”‚ tier AND this alert is β”‚ β”‚ a general (non-security)β”‚ β”‚ event? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ yes β”‚ β”‚ no β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ SKIP - security-only β”‚ β”‚ β”‚ tier, patch by hand β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 3. ACQUIRE LOCK (no database, no Postgres) β”‚ β”‚ Semaphore: run patchmon_tag_host.yml, action=add β”‚ β”‚ β€’ Proxmox guest β†’ tag "being_patched" on the Proxmox API, β”‚ β”‚ check-then-set inside one Ansible task run β”‚ β”‚ β€’ non-guest β†’ atomic "mkdir /run/patchmon-being-patched"β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Lock acquired? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ no β”‚ β”‚ yes β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ EXIT - another host β”‚ β”‚ β”‚ in the lock group is β”‚ β”‚ β”‚ already being patched β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ UptimeKuma maintenance β”‚ β”‚ enabled? (Part 3) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ yes β”‚ β”‚ no β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ Open Kuma maintenanceβ”‚ β”‚ β”‚ window for this host β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Is a Proxmox guest? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ yes β”‚ β”‚ no β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ 4. SNAPSHOT (guests only) β”‚ β”‚ β”‚ Semaphore: snapshot β”‚ β”‚ β”‚ template, poll to done β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ Snapshot succeeded?β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ no β”‚ β”‚ yes β”‚ β–Ό β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό β”‚ ABORT: alert, β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ release lock β”‚ β”‚ 5. PATCH β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Semaphore: run chosen template, Limit=hostβ”‚ β”‚ poll (generous timeout, up to 45 min) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 6. VERIFY (via PatchMon's own count) β”‚ β”‚ ansible ok + 0 pending β†’ SUCCESS β”‚ β”‚ ansible ok + still pending β†’ PARTIALβ”‚ β”‚ ansible error / timeout β†’ FAILURE β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ success β”‚ partial β”‚ failure β”‚ β–Ό β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ βœ… success β”‚ β”‚βš οΈ partial +β”‚ β”‚ πŸ”₯ failure β”‚ β”‚ alert β”‚ β”‚ 24h cooldownβ”‚ β”‚ alert w/ β”‚ β”‚ β”‚ β”‚ tag + alert β”‚ β”‚ log excerptβ”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 7. CLEANUP (always runs once locked) β”‚ β”‚ Semaphore: patchmon_tag_host.yml, β”‚ β”‚ action=remove - this IS the lock β”‚ β”‚ release (tag removed / dir deleted) β”‚ β”‚ + remove Uptime Kuma maintenance β”‚ β”‚ window (optional) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## Step 3: Import the Poll-Semaphore Task sub-workflow Since the workflow is quite large, it is best to split it into a parent-child relationship. If we take a step back and think about on which occasions Semaphore would be called, it's the following: - Inventory lookup - confirm host details & tags - Tagging - apply or remove a tag (Proxmox guests only) - `being_patched` . - Snapshotting - create a snapshot before a patch is applied - Patching - carry out a runbook relevant to that type of host after snapshot has completed - Maintenance toggling *(optional, Part 3)* - open or close an Uptime Kuma maintenance window around the patch run Import the .JSON workflow into n8n: πŸ“Ž [PatchMon - Poll Semaphore Sub-Task.json](attachments/PatchMon - Poll Semaphore Sub-Task.json) ### Sub-workflow explained This sub-workflow exists so the β€˜kick off a Semaphore task and wait for it’ logic is written once instead of four times. The parent hands it a `taskId` and a `timeoutMinutes` value that's different per call - lookups, tag add/remove, and maintenance start/stop all get 2 minutes (`POLL_TIMEOUT_MINUTES_TAG`), snapshots get 10, patches get 45 (they need the room for reboots) - and it hands back a status and the raw task output text once the Semaphore task is done or the clock runs out. ```bash β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ On Execute (from parent) β”‚ ← Execute Workflow nodeaces β”‚ inputs: baseUrl, projectId, β”‚ 7 types: lookup, tag-add, snapshot, β”‚ taskId, timeoutMinutes, β”‚ patch, tag-remove, β”‚ pollIntervalSeconds β”‚ maintenance-start and stop β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Set Start Time β”‚ startedAt = now() β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Wait Before Poll │◄────────────────────────────┐ β”‚ sleeps pollIntervalSecs β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ GET Semaphore Task Status β”‚ GET /tasks/{taskId} β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ β”‚ Merge Timing Fields β”‚ elapsedMinutes = β”‚ β”‚ β”‚ (now βˆ’ startedAt) / 60s β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β–Ό β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ Finished or timed out? β”‚ β”‚ β”‚ status ∈ {success,error, β”‚ β”‚ β”‚ stopped} OR elapsed β‰₯ β”‚ β”‚ β”‚ timeout β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ no β”‚ β”‚ yes β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ GET Semaphore Task Output β”‚ GET /tasks/{taskId}/raw_output β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Set Final Result β”‚ Status: success/error/stopped/timeout β”‚ output: the task's β”‚ β”‚ raw log text) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό Returns to the parent's Execute Workflow node ``` ### Create a Semaphore API token and add it into n8n - On your Semaphore UI instance, click on your username at the bottom left and go to API Tokens β†’ click on the β€˜New token’ button. - Value: `PatchMon - Poll Semaphore Task` - Expires: Never - Save it in your password manager (1password / Bitwarden / Vaultwarden, etc.), as you will not be able to see that token secret anymore. ### Set up a Bearer token in n8n - Go into the imported workflow, find the tile called β€˜GET Semaphore Task Status’. - Generic Auth Type: Bearer Auth - Bearer Auth - click on the β€˜Set up credential’ button - In the new window, set up the authentication variable: - Value: paste the token value from Semaphore - Also set the name in the top left corner to ensure that it is not just called β€˜Bearer Auth’.

2 set up a bearer token in n8n

- Re-open the same tile and choose the new bearer token from the drop-down list. - Find the other tile called β€˜GET Semaphore Task Output’ and apply the same header as well. ## Step 4: Set up helpers for the main workflow For the main workflow to work, we will need to launch two playbooks - one that gets the groups, the other that deals with adding a tag when a host is being patched. ### Patchmon lookup playbook In order to detect what are the groups that the host to be patched is a member of, we need to call a little harmless playbook in order to get the basics - no changes are made, no SSH connection is needed. Add this to your Gitea or other source version control system. - The expected location and file name is `helpers/patchmon_lookup.yml` πŸ“Ž [patchmon_tag_host.yml](attachments/patchmon_tag_host.yml) - In Semaphore, add a new template. Remember to add the Proxmox vault information to be able to reach each cluster/standalone host. Task Templates β†’ New template. - Name: `Patchmon Lookup Host` (or similar) - Path to playbook file: `helpers/patchmon_lookup.yml` - Inventory: All Sites - Repo: your repo - Vaults: Proxmox Vault - Tick the box for limit, which will be supplied in the HTTP request from n8n

3 patchmon lookup playbook

- Note down the task ID once you open the template.

4 patchmon lookup playbook

### Patchmon Tag Playbook The second playbook is concerned with adding and removing a tag called `being_patched` (change it to something else if you prefer). It is done in such a way (using `ansible.builtin.uri`) that will work around the situation of us having more than one set of Proxmox credentials. - Create a file under `helpers/patchmon_tag_host.yml` . πŸ“Ž [patchmon_tag_host.yml](attachments/patchmon_tag_host.yml) - Add it in Semaphore in the same way as we did it with the previous playbook. - Remember to tick the β€˜Limit’ box. ## Step 5: Import the main Patchmon Orchestrator Workflow Now when we have the ID of the sub-workflow, we can proceed with importing the main one. πŸ“Ž [PatchMon Auto-Patch Orchestrator.json](attachments/PatchMon Auto-Patch Orchestrator.json) ### Semaphore connection & task templates | Variable | Description | | --- | --- | | `SEMAPHORE_BASE_URL` | Base URL of your Semaphore instance, e.g. `http://192.168.8.26:3000`. | | `SEMAPHORE_PROJECT_ID` | Semaphore project ID all templates below belong to. | | `SEMAPHORE_LOOKUP_TEMPLATE_ID` | Template ID for the Lookup task (`patchmon_lookup.yml`). Resolves group membership, Proxmox identity, and alert-cooldown status. | | `SEMAPHORE_TEMPLATE_SNAPSHOT` | Template ID for the Proxmox snapshot task (`proxmox_snapshot_host.yml`). | | `SEMAPHORE_TEMPLATE_TAG_HOST` | Template ID for the lock acquire/release task (`patchmon_tag_host.yml`). | | `SEMAPHORE_TEMPLATE_GALERA` | Template ID for the Galera patch playbook (`patch_debian_galera.yml`). | | `SEMAPHORE_TEMPLATE_NGINX` | Template ID for the Nginx patch playbook (`patch_debian_nginx.yml`). | | `SEMAPHORE_TEMPLATE_DEBIAN` | Template ID for the general Debian/Ubuntu patch playbook (`patch_debian_single_hosts.yml`). Fallback template for unmatched hosts. | | `SEMAPHORE_TEMPLATE_UPTIMEKUMA_MAINTENANCE` | Template ID for starting/stopping an Uptime Kuma maintenance window (if you followed Part 3 of this series). | ### Alerting | Variable | Description | | --- | --- | | `ALERT_WEBHOOK_URL` | Discord/Telegram webhook for success, partial, and failure alerts. Treat as a secret. | ### Polling behavior | Variable | Description | | --- | --- | | `POLL_TIMEOUT_MINUTES_LOOKUP` | Minutes n8n polls a running Lookup task before timing out. | | `POLL_TIMEOUT_MINUTES_TAG` | Same, for the tag add/remove (lock) task. | | `POLL_TIMEOUT_MINUTES_SNAPSHOT` | Same, for the snapshot task. | | `POLL_TIMEOUT_MINUTES_PATCH` | Same, for the patch task. Needs the most headroom: dist-upgrade plus reboot takes longest. | | `POLL_INTERVAL_SECONDS` | How often n8n checks task status while polling. | ### Host filtering & patch scope | Variable | Description | | --- | --- | | `EXCLUDED_HOSTNAMES` | Comma-separated hostnames that never trigger a patch run. | | `SECURITY_ONLY_GROUPS` | Comma-separated inventory groups limited to security updates only. Empty: full updates everywhere. | ### Uptime Kuma integration | Variable | Description | | --- | --- | | `UPTIMEKUMA_MAINTENANCE_ENABLED` | On/off switch for Uptime Kuma maintenance windows. Must be a String field ("true"/"false"), not Boolean. | | `UPTIMEKUMA_DURATION_BUFFER_MINUTES` | Extra buffer minutes added to the estimated patch duration for the maintenance window. | | `UPTIMEKUMA_INSTANCES` | JSON array of Uptime Kuma instances: `name` and `url` per entry. | ### Workflow explained in short - `Normalize Trigger Payload` + `Global Config` are the setup stage. Fill in manually as explained above. - `Is Host Excluded?` / `Skip - Host Excluded` is the hard exclude gate. - `Start Semaphore Lookup Task` through `Parse Group Names` is host resolution. Ensure the sub-workflow is mapped there. - `In Alert Cooldown?` is the repeat-alert guard. - `Decide Template + Lock Key` is the one Code node you will actually maintain as your fleet grows. It's a small priority-ordered rule table mapping Ansible groups to Semaphore template IDs and lock keys. - `Standard Update on Security-Only Host?` is the security-tier gate. - `Start Tag-Add Task` (ensure the sub-workflow is mapped there) through `Lock Acquired?` is the locking step (Proxmox tag or local `mkdir`, decided automatically inside the playbook by whether `proxmox_vmid` is defined). - **UptimeKuma Maintenance Enabled? (Start)** opens a maintenance window on every configured Kuma instance for this host before it's touched. Off by default; when off (or misconfigured) it routes straight through to `Is Proxmox Guest?` untouched. - `Is Proxmox Guest?` onward splits into snapshot-then-patch for guests, or patch-only for everything else. - `Patch Outcome` is the three-way verification switch. - Everything funnels into `Start Tag-Remove Task `(ensure the sub-workflow is mapped there) / `Poll Tag-Remove Task `(again, check the mapping), which is both the alerting cleanup and the actual lock release. - **UptimeKuma Maintenance Enabled? (Stop)** sits right before `Start Tag-Remove Task` - every alert path (success, partial, failure, even a failed snapshot) passes through it, so the window is always closed the same way it was opened, and a host is never left stuck "in maintenance." ### Patchmon Auto-Patch Orchestrator in an ASCII flowchart: ```bash Incoming PatchMon Webhook [Trigger] β”‚ β–Ό Normalize Trigger Payload (hostname, eventType, severity, hostId, pendingCount, pendingThreshold) β”‚ β–Ό Global Config (every non-secret setting lives here, CE has no Variables/Environments panel) β”‚ β–Ό Is Host Excluded? ──yes──► Skip - Host Excluded (exit) β”‚no β–Ό Start Semaphore Lookup Task [sub-workflow] ──► Poll Lookup Task ──► Parse Group Names β”‚ β–Ό In Alert Cooldown? ──yes──► Skip - In Alert Cooldown (exit) β”‚no β–Ό Decide Template + Lock Key β”‚ β–Ό Standard Update on Security-Only Host? ──yes──► Skip - Standard Update on Restricted Host (exit) β”‚no β–Ό Start Tag-Add Task [sub-workflow] ──► Poll Tag-Add Task ──► Parse Tag-Add Result β”‚ β–Ό Lock Acquired? ──no──► Lock Busy - Exit (exit) β”‚yes β–Ό UptimeKuma Maintenance Enabled? (Start) ──no─────────────────────────┐ β”‚yes β”‚ β–Ό β”‚ Start Maintenance-Start Task [sub-workflow] β”‚ ──► Poll Maintenance-Start Task β”‚ ──► Parse Maintenance-Start Result β”‚ β”‚ β”‚ β–Ό β”‚ Is Proxmox Guest? β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚no────────────────────────────────────────────────────────────┐ β”‚yes β”‚ β–Ό β”‚ Start Snapshot Task [sub-workflow] ──► Poll Task ──► Snapshot Ok? β”‚ β”‚ β”‚ β”‚no yesβ”‚ β”‚ β–Ό β–Ό β”‚ Snapshot Failed Alert Context Start Patch Taskβ—„β”€β”€β”€β”€β”€β”˜ β”‚ [sub-workflow] β”‚ β”‚ β”‚ β–Ό β”‚ Poll Patch Task ──► Parse Patch Verify Result β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”‚ Patch Outcome (3-way) β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ successβ”‚ partialβ”‚ failureβ”‚ β”‚ β–Ό β–Ό β–Ό β”‚ Send Discord/Telegram Build Partial Build Failure β”‚ Success Message Alert Context Alert Context β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β–Ό β”‚ β”‚ Send Discord/Telegram Alert β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β–Ό β–Ό └───────────────────►────────────────►─────────────────►────┐ β”Œβ”€β”€β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β—„β”€β”€β”€β”€β”˜ β”‚ β–Ό UptimeKuma Maintenance Enabled? (Stop) ──no──────────────────┐ β”‚yes β”‚ β–Ό β”‚ Start Maintenance-Stop Task [sub-workflow] β”‚ ──► Poll Maintenance-Stop Task β”‚ ──► Parse Maintenance-Stop Result β”‚ β”‚ β”‚ β–Ό β”‚ Start Tag-Remove Task β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ [sub-workflow] ──► Poll Tag-Remove Task β”‚ β–Ό (Exit - releases the lock either way) ``` Before we can really run this workflow, we will need to configure Patchmon to send out webhooks when new updates are available. So run the test trigger for the workflow and head to Patchmon. ## Step 6: Finalize the connection with Patchmon The whole workflow starts with Patchmon. It is this very system that serves as a source of truth in terms of what updates are pending, whether standard or security updates. We will need to configure it to send notifications to n8n, which will then trigger patching jobs with Semaphore UI. Then, we will trigger a re-check by a Patchmon agent and send alerts via Discord, if required. ### Create an Alert Channel Since we now have a webhook created in n8n, we can link it in Patchmon. - Go to Reporting β†’ Click on the β€˜Add Destination’ button and select β€˜Webhook’.

5 create an alert channel

- Test the webhook: - Go to n8n and click on β€˜Execute Workflow’ - Click on the β€˜Test’ button in Patchmon to fire a β€˜Test’ button while still in the Destinations section. > πŸ’‘ **Note** > > Note: n8n’s **test **webhook differs from the **production **one - the test one is executed once when being actively triggered (waiting for a webhook to come), whereas the production webhook will trigger even when you do not actively press the β€˜Execute’ button. For the production trigger to work properly, always publish your workflow to ensure you are not triggering on an older version of your workflow. ### Configure a route for the webhook With the webhook created, go to β€˜Event Rules’ sub-section (within Reporting) and click on β€˜Add event rule’. Select these two events: - Pending updates threshold exceeded - Security updates threshold exceeded - Minimum severity: `Informational`

6 configure a route for the

### Configure thresholds I recommend taking a quick look at the [**manual related to Patchmon’s thresholds**](https://patchmon.net/docs/patchmon-admin-guide). Patchmon needs to know on a per-host basis what is the acceptable threshold of accumulated updates before it fires the n8n workflow to apply a patch. - Firstly, go to Reporting β†’ Alert Lifecycle β†’ scroll down to β€˜Alerts system’. Make sure that the toggle for master switch is on.

7 configure thresholds

- Once on, scroll down and toggle the following two to enable them: - Host Security Updates Exceeded - Host Pending Updates Exceeded (consider covering these later once you run a few tests with the security updates) - Severity: Warning - Regarding the threshold, I recommend finding a host with that is the most out of date and setting the threshold one below that, so that during testing, you will not have too many webhooks coming into n8n. - Later on once tested, the recommended value of 1 for security updates and 10 for standard updates is fine.

8 configure thresholds

> πŸ’‘ **Note** > > Just be careful about not setting the limit for updates too low, or you may a large number of hosts to update, which may clog up your IO resources. >

9 configure thresholds

## Step 7 - Run, run, auto-patching workflow! Getting it work initially will likely require a number of attempts. In my experience, it was best to configure updates coming out for just one host. Taking a manual snapshot of it is advisable before you verify that the workflow-driven one works. - My most out of date host was `mail1` - look at how badly outdated this iRedMail host was, it is almost embarrassing to share!

10 step 7 run run auto patching

- Once the workflow kicks in (ensure you clicked through every tile and provided the required variables in the Config tile), the first job that will be triggered in Semaphore is the lookup task.

11 step 7 run run auto patching

- If all good and it is a Proxmox guest (VM or an LXC), a snapshot is taken. In your Proxmox UI, observe that a snapshot was, in fact, taken, by the tag being added:

12 step 7 run run auto patching

- Then the `Decide Template + Lock Key` is loaded - you may need to configure playbooks as per your type of servers - This is then followed by the actual patching job being called in Semaphore, as per your configured playbooks and then polled for the result. The playbook forces a re-check via the Patchmon agent at the end.

13 step 7 run run auto patching

- And lastly, a nice Discord or Telegram message πŸ™‚ What more can you want?

14 step 7 run run auto patching

- Now let’s check the status in Patchmon!

15 step 7 run run auto patching

### Troubleshooting your patching job executions Lots of things can and will go wrong, so let’s look into a few: - **No workflows coming to n8n** - check the Delivery log section (`your_serever:port/reporting?tab=alerts`) to see if a webhook even went out. If yes and it errored out, check what the error is. Error 404 indicates the webhook address is incorrect or there was nothing listening on that (when using the test hook, you need to click on the β€˜Execute’ button).

16 troubleshooting your patching

- **Error when taking a snapshot** related to Proxmox - ensure that the credentials saved in Semaphore (such as via Ansible Vault) are correct. If needed, update them in Semaphore or re-create the Ansible Vault and then reflect it in the `group_vars/` folder.

17 troubleshooting your patching

Once you are confident with the process, you can decrease the threshold for updates in Patchmon to ensure that your fleet gets patched. Since this is quite a complex workflow, do let me know if you identify any bugs or make improvements that would be worth sharing with others. ## Step 8 - Patch Security updates only Let's say that you have a group of hosts called **webservers **and you want them to receive security updates only. This group was already created in Part 1 of this series - the web1 and web2 VMs are tagged as β€˜web’. - Let's add β€˜web1’ and β€˜web2’ into a group called β€˜webservers’ in terms of their Proxmox tags. - At the start, β€˜web1’ had a number of pending updates waiting, both security and standard, as shown below:

18 step 8 patch security updates

19 step 8 patch security updates

- The result was that only the security updates got applied!

20 step 8 patch security updates

- Verify the finding in Patchmon:

21 step 8 patch security updates

This is pretty awesome! You can consider applying this for your Proxmox hosts and other critical services that you want to keep up to date in terms of security, but would otherwise prefer to update manually. ## Conclusion The Part 4 of this series closes the loop: **PatchMon **spots the drift, **n8n **decides whether and how to act on it, **Semaphore** does the actual work, and **Uptime Kuma** keeps your alerting quiet while it happens and the monitors the hosts afterwards, in case something happens post-patching. We have got a fleet that patches itself, snapshots itself first, verifies its own work, and tells us about it on Discord or Telegram - no database of its own anywhere in the loop, just Proxmox tags and a marker file standing in for locks. A few things worth doing before you point this at production: - Put these instances (n8n, Semaphore, Patchmon, Kuma) behind a **reverse proxy** running on **HTTPS only**. - Implement **firewall rules **on these hosts (`iptables`, `nftables`, `ufw` or similar) that allows connection only from your server subnet. - Add **fail2ban** on each of these hosts, allowing only certain endpoints for SSH access. - The workflow is deliberately stateless, no lock table, no external queue. That may work well in a home environment. Originally, I prepared this tutorial by saving all information in a `postgresql` database, which helps with retrieval and locks. However, I wanted to simplify the process and not add another system to manage as part of the process. On a larger scale, however, it would likely be the right approach. Did you find a bug, or improve on something here? Let me know in the comments! I would rather fix it for everyone than to have five people independently trip over the same edge case. Knowledge is to share - primarily with other humans, then our fellow AI friends :)