PVE-UPS — User Manual
A lean UPS shutdown appliance for Proxmox VE: it monitors one or more UPS devices — via SNMP or through a NUT server — and, on a power outage, shuts down the assigned Proxmox hosts in an orderly fashion, without hand-written configuration files anywhere. This manual is aimed at technically minded administrators who are new to the product.
1 What is PVE-UPS?
PVE-UPS replaces classic, vendor-locked UPS shutdown appliances (such as APC PowerChute Network Shutdown) for Proxmox environments. The software runs as a single, resource-friendly unprivileged LXC (~256 MB RAM) and needs nothing but one installer script.
Core features
- Monitoring of one or more UPS devices — with an SNMP network card (standard RFC 1628 or a vendor MIB such as APC PowerNet, SNMP v1/v2c/v3, pure-Python implementation) or through a NUT server, which is how USB and serial UPS devices are read.
- Orderly host shutdown through a Proxmox API token (privilege
Sys.PowerMgmt) — no root SSH required. - Host↔UPS mapping with per-host logic (AND/OR) for redundant power supplies or split load, including a feed diagram in the web UI.
- Complete setup through a web wizard with test buttons.
- Web UI in English and German — picked automatically from the browser language.
- Fail-safe: losing contact with the UPS raises an alarm by default, it never shuts anything down.
- Dry-run mode for a risk-free commissioning.
- Read-only REST/JSON status for monitoring (
/api/status,/api/health).
2 Requirements
- A Proxmox VE host (standalone — no cluster/HA-manager interaction) that can run the LXC. Typically one of the hosts to be protected.
- One or more UPS devices, reachable in one of two ways:
- with an SNMP network card exposing the standard RFC 1628 UPS MIB (vendor-independent) or a supported vendor MIB — APC PowerNet, which also covers APC cards without RFC 1628 (see section 5), or
- attached by USB or serial cable to a machine that runs a
NUT server (
upsd) — see section 5.
- Network connectivity between the appliance and the UPS source (read access is enough: UDP 161 for SNMP, TCP 3493 for NUT) and to the Proxmox API of the protected hosts (HTTPS, port 8006).
3 Installation & setup
Install directly from the Proxmox node shell (web UI → node →
>_ Shell, as root). The script downloads the latest release package
from GitHub, unpacks it and creates the LXC:
bash -c "$(curl -fsSL https://github.com/ffind-dev/pve-ups/releases/latest/download/install.sh)"
# e.g. with a static IP:
curl -fsSL https://github.com/ffind-dev/pve-ups/releases/latest/download/install.sh | bash -s -- \
--ctid 950 --ip 10.0.0.50/24 --gateway 10.0.0.1 --hostname pve-usv
Then open the web UI at http://<container-ip>:8080 and walk
through the wizard:
- Set the UI password.
- Add the UPS devices (source, address, credentials — see section 5) and verify each one with “Test UPS”.
- Add the Proxmox hosts (API token, see section 4) and use “Test”. Define which UPS devices feed each host and with which logic (AND/OR).
- Set the thresholds or keep the defaults.
- Optionally configure the notification webhook (HTTP POST to a ticket/chat/monitoring system).
4 Creating the Proxmox API token (per host)
On each Proxmox host, create a dedicated user with only the power-management privilege and an API token — once:
pveum user add ups@pve
pveum role add UpsShutdown -privs "Sys.PowerMgmt"
pveum acl modify /nodes -user ups@pve -role UpsShutdown
pveum user token add ups@pve shutdown --privsep 0
The last command prints the token ID (ups@pve!shutdown) and
the secret. Enter both in the wizard under “Proxmox hosts” and verify with
“Test”. The token can be revoked at any time and replaces root SSH access.
5 UPS sources
Every UPS entry in the wizard has a “Read via” selector. Both sources can be mixed freely in one instance, and everything after this point — thresholds, host mapping, fail-safe rules — is identical for either.
SNMP (UPS with a network card)
In the web interface of the UPS network card (usually under Network → SNMPv1 or SNMPv3), enable access and grant the appliance IP read access. Nothing is ever written. Default port 161/UDP.
Which MIB is read
Every SNMP UPS has a MIB setting. It decides which set of objects is asked for — the values themselves, the thresholds and everything downstream are the same either way.
- Automatic (default, and the right answer for almost every device): the standard RFC 1628 is read, and the UPS is switched to a vendor MIB as soon as it answers there. Detection costs no extra query, and a firmware update that adds RFC 1628 later is picked up on its own.
- RFC 1628 (standard): the vendor-independent UPS MIB, nothing else.
- APC PowerNet: the MIB of APC/Schneider network management cards.
A vendor MIB matters for APC in particular. Schneider supports RFC 1628 only on
Network Management Card 2 (AP9630/AP9631/AP9635) from firmware
sumx/sy v5.1.7 on; the older NMC1 cards
(AP9617/AP9618/AP9619) speak PowerNet only and cannot be read through the standard at
all. On cards that do support both, PowerNet is still the better source: it reports
the remaining runtime in hundredths of a second rather than whole minutes, and it
reports a self-test as its own state — RFC 1628 cannot tell a self-test apart
from a real outage.
Set the MIB explicitly only to force one, for instance to pin a device to the standard while comparing readings.
NUT server (USB and serial UPS devices)
A UPS without a network card is reached through
Network UPS
Tools: whichever machine the UPS is plugged into runs upsd, and
PVE-UPS reads from it over TCP 3493. Very often such a server already exists —
Synology and QNAP NAS boxes have a “UPS server” checkbox, as do TrueNAS and
OPNsense; otherwise a Raspberry Pi or a Proxmox host with the nut-server
package will do.
Three fields are needed: the host running upsd, the UPS name as
configured in its ups.conf (upsc -l <host> lists the
available names), and optionally a user name and password — a default upsd
allows reading variables without either.
upsmon. NUT acts
as the device driver; the thresholds, the host policy and the shutdown decision
stay in this appliance. There is nothing to configure on the protected hosts.
Reading the test result
Below the result line, “Test UPS” offers Details per object — every object queried individually, with its value or the reason it is missing. It unfolds on its own whenever something is wrong, which separates the cases that otherwise look identical:
- Nothing answers (every line an error): address, port or credentials are
wrong, or a firewall blocks UDP 161 (SNMP) / TCP 3493 (NUT). For NUT,
also check that upsd's
LISTENdirective covers this host — by default it only listens on localhost. - Some objects answer, others say
noSuchObject/noSuchInstance(SNMP) or “not provided by this driver” (NUT): the device simply does not publish those values. Whenever that affects a trigger, the test says so in plain words above the table — for example a driver withoutbattery.runtimemakes the “Runtime below (min)” threshold permanently ineffective. Use the “On battery longer than (s)” timer instead: it runs on the appliance's own clock and always works. noSuchNameunder SNMPv1: the object is missing and v1 aborts a multi-object query as soon as one object is absent — the regular poll then fails completely even though the other values are readable. Switch the UPS to v2c if it supports it.- Every RFC 1628 object missing, every vendor object answering: normal on an APC card without RFC 1628 support. In Automatic mode both MIBs are listed, the one actually in use comes first, and the line above the table names it. Nothing needs changing — the counted objects and the trigger warnings always refer to the MIB being used, not to the one that lost.
- “stale data” (NUT): upsd answers, but its driver has lost contact with the UPS. The values it still serves are old, so PVE-UPS treats the UPS as unreachable — an alarm, never a shutdown. Restart the NUT driver on that machine.
6 How it works & logic
The engine polls all UPS devices in parallel and evaluates each UPS independently. Per UPS there is a simple state sequence:
ONLINE on mains → ON_BATTERY power outage, UPS on battery → triggered as soon as a threshold fires
Which host shuts down?
That is decided by each host's mapping to its UPS devices and its logic:
- AND (
ups_policy = all, default): the host only shuts down once all of its assigned UPS devices have triggered — exactly the behaviour for servers with redundant power supplies (one UPS per feed). As long as one UPS still delivers power, the host stays up. - OR (
ups_policy = any): the host shuts down as soon as one assigned UPS triggers — for split, non-redundant load.
Multiple due hosts are shut down in order. The host carrying the appliance
itself (this_host) always goes last, so it can still shut down
the others. Tip: attach the appliance host to all UPS devices so it reliably
becomes due last.
If a required UPS recovers (mains returns), a shutdown that has not been executed yet is aborted and the state is reset.
7 Fail-safe & edge cases
Guiding principle: a lost SNMP connection is not a confirmed power outage — the UPS may just be briefly unresponsive, or a switch in between is down. By default a communication loss therefore raises only an alarm, no shutdown. Two optional settings refine this:
- Continue the shutdown on loss (default on): if contact with the UPS is lost after on-battery operation was already confirmed, a started on-battery countdown keeps running on the local clock and fires when it expires. Only this time-based trigger survives the “blind” state (runtime and charge are unreadable then). If a trigger (e.g. battery-low or the charge threshold) had already fired before the connection dropped, it stays latched and is not downgraded to the time countdown. Both the countdown and the triggered state also survive a service restart: they are persisted to disk and restored on start (max. 24 h old).
- Shutdown after X minutes of pure loss (default off): treats a pure communication loss (without previously confirmed battery operation) as an outage after X minutes. A deliberate opt-in for environments where a prolonged loss of contact counts as critical.
Case overview
| Situation | Behaviour |
|---|---|
| On mains, all good | nothing (shows “On mains”) |
| On battery, a trigger fires | shutdown (hosts in order, own host last) |
| Contact lost while on mains | alarm only, no shutdown |
| Contact lost during a running battery countdown | countdown continues, shutdown when it expires |
| Contact lost after an already fired trigger | trigger stays latched (no downgrade) |
| Pure loss of contact with the opt-in active | shutdown once the threshold expires |
| NUT server answers with stale data | counts as unreachable (alarm), never as “on mains” |
| Dry-run active | trigger is detected + logged, nothing is shut down |
| Mains returns | state is reset |
8 Triggers & thresholds
One matching condition is enough. All values can be changed in the wizard (empty field = condition off); thresholds are set globally and can be overridden per UPS.
| Condition | Default |
|---|---|
| On battery longer than | 600 s |
| Runtime below | 10 min |
| Charge below | 30 % |
UPS reports battery low/depleted | on |
Poll interval: 30 s on mains, 8 s on battery.
Battery-low, runtime and charge thresholds fire immediately — independent of the time countdown. The countdown shown on the dashboard refers exclusively to the "on battery longer than" condition and disappears as soon as the UPS has triggered. Whether a host then shuts down right away is decided by its AND/OR logic (see How it works & logic): with AND it waits until all of its feeding UPS devices have triggered.
9 Monitoring / REST
Two read-only endpoints are available without authentication and without secrets:
GET /api/status— the complete state: appliance, all UPS devices, hosts, shutdown status, plus the events of the last 48 h including a severity summary.GET /api/health—200when the engine is running (with version and UPS reachability), otherwise503. Ideal for uptime checks.
curl http://<container-ip>:8080/api/status | jq
# only critical events of the last 48 h:
curl -s http://<container-ip>:8080/api/status \
| jq '.events[] | select(.severity=="critical")'
All modifying endpoints (saving the wizard, test shutdown) are password-protected.
10 Updates & operation
Updates can be uploaded directly in the web UI; a slim, privileged companion service applies them (the app itself stays unprivileged). After the upload, the update is typically applied within a few seconds. The web UI also lets you export/import the configuration, set the NTP server and timezone, and view/clear the event log.
Note: the timezone (System section, e.g. Europe/Berlin)
should be set, otherwise the container runs in UTC. The self-test start time
is interpreted in this local time; after setting the timezone the service restarts
briefly.
Self-test of the Proxmox connection
The appliance checks on its own whether the API token and the
Sys.PowerMgmt privilege still work per host — so an expired or revoked
credential surfaces long before an outage needs it. Two settings in the
System section control it: a start time (full hour) and an
interval from 15 minutes up to 24 hours. Both together define fixed times of
day: start 09:00 with a 6-hour interval means 09:00, 15:00, 21:00 and 03:00. The
default is daily.
A failed self-test is always logged and notified. A successful one is written to the event log at most once a day (and whenever it recovers from a failure), so a short interval does not bury the event log. While a UPS is on battery the self-test is skipped entirely — during an outage the countdown has priority. A restart does not repeat a self-test that already ran in the current slot.
Updating from version 2.x to 3.x
Download the current release package (pve-usv-<version>.tar.gz)
from the GitHub releases page and upload it in the web UI under “Update” — the
configuration is fully preserved. Two behaviour changes: e-mail notifications are
gone (a previously configured SMTP entry is removed automatically on the next save;
the webhook remains), and event/webhook texts are English as of 3.0.0.
Docker deployment
As an alternative to the LXC install, a prebuilt image is published on every
release to ghcr.io/ffind-dev/pve-ups. A
ready-made docker-compose.example.yml is in the
repository; the essentials are:
services:
pve-ups:
image: ghcr.io/ffind-dev/pve-ups:latest
container_name: pve-ups
restart: unless-stopped
ports: ["8080:8080"]
environment:
- TZ=Europe/Berlin
volumes:
- pve-ups-config:/etc/pve-usv # config.yaml (secrets, 0600)
- pve-ups-data:/var/lib/pve-usv # events.db + engine state
volumes:
pve-ups-config:
pve-ups-data:
Then open http://<docker-host>:8080 and run through the same
wizard as with the LXC. Configuration and event log persist via the two mounted
volumes, so docker compose down does not lose anything.
Because there is no privileged companion process (no systemd) in the image, two
things work differently there: updates are applied by pulling a new image tag and
recreating the container (docker compose pull && docker compose up
-d) instead of the in-app uploader, and NTP/timezone are the Docker
host's/orchestrator's responsibility rather than being set from the wizard — both
areas are hidden in the web UI. Everything else (UPS polling, Proxmox shutdown,
thresholds, webhook, self-test) is identical.
TZ on the container. The self-test start time is
interpreted in the container's local time, and without TZ the
container runs in UTC — the timezone cannot be set from the web UI in Docker
mode.
172.17.0.0/16 –
172.31.0.0/16, move Docker's default address pool before
starting the container — Docker claims that range for its bridges, and the
container would then no longer reach a UPS or a Proxmox host in it. In
/etc/docker/daemon.json, e.g.
{"bip":"10.210.0.1/24","default-address-pools":[{"base":"10.211.0.0/16","size":24}]},
then systemctl restart docker.
11 Troubleshooting
Update stays in the queue
If an uploaded update stays at “⏳ Queued”, the queue drainer is not active on this instance. This happens once on boxes updated from a very old version. Run once inside the container (enables the service permanently):
pct exec <CTID> -- systemctl start pve-usv-agent.service
# or directly inside the container:
systemctl start pve-usv-agent.service
UPS unreachable / alarm
Check the address, credentials and the firewall between the appliance and the UPS source. “Test UPS” in the wizard shows the exact cause — unfold Details per object to see which object failed and why (see UPS sources). A communication loss alone never shuts anything down (fail-safe).
A threshold never fires
Not every device reports every value. Run “Test UPS”: if the device does not publish the remaining runtime or the charge, the test says so above the object table, and the matching threshold is permanently ineffective. Use “On battery longer than (s)” instead — that timer runs on the appliance's own clock.
On an SNMP UPS, check the MIB setting as well. Many cards publish through their vendor MIB what they leave out of RFC 1628; Automatic finds that on its own, so this is only worth a look if the MIB was pinned by hand (see UPS sources).
An APC UPS does not work / answers nothing useful
Leave the MIB setting on Automatic — the APC PowerNet MIB is then
used by itself. It is needed because Schneider supports RFC 1628 only on Network
Management Card 2 (AP9630/AP9631/AP9635) from firmware
sumx/sy v5.1.7 on, while the older NMC1 cards
(AP9617/AP9618/AP9619) do not implement it at all. “Test UPS” shows which MIB was
used and which objects each one answered.
NUT: “does not know a UPS with that name” or nothing answers
The UPS name must match the section name in upsd's ups.conf;
upsc -l <host> lists the valid names. If nothing answers at all,
upsd is most likely listening on localhost only — its LISTEN directive
has to cover the interface the appliance connects to, and TCP 3493 must be
open.
SNMPv3 with encryption fails
If an authPriv user reports “Ciphering services not available” or the test
points at missing encryption, the appliance is missing the Python package
cryptography, which provides the DES/AES ciphers. Versions up to 3.0.0
did not install it. Updating the appliance fixes this — the package comes
along automatically. Two things worth knowing: it affects every privacy protocol,
so switching from DES to AES does not help, and authNoPriv (authentication
MD5/SHA, privacy “none”) keeps working in the meantime, because authentication does
not need those ciphers.
Internal names: pve-usv
The product name is PVE-UPS, but the service and paths are technically still
called pve-usv — e.g. systemctl status pve-usv,
/etc/pve-usv/config.yaml and /var/lib/pve-usv/. This is
intentional and keeps updates of existing installations compatible.
12 Changelog (highlights)
Simplified, user-visible highlights. The full technical changelog lives in the GitHub repository.
- APC UPS devices are supported properly: an SNMP UPS can now be read through a vendor MIB instead of RFC 1628, the first one being APC PowerNet. This covers APC cards that RFC 1628 cannot reach at all — the older NMC1 (AP9617/AP9618/AP9619) — as well as NMC2 cards on firmware below sumx/sy v5.1.7. See UPS sources.
- The new MIB setting defaults to Automatic and needs no attention: existing UPS entries get it on update, and a UPS moves to its vendor MIB by itself as soon as it answers there.
- Cards that support both get more accurate readings: PowerNet reports the remaining runtime in hundredths of a second instead of whole minutes, and it marks a self-test as such — RFC 1628 cannot tell one apart from a real outage.
- The UPS test explains itself: in Automatic mode it lists both MIBs, says which one it settled on and why, and the dashboard names the MIB in use.
- UPS devices without a network card can now be monitored: a UPS entry is read either via SNMP as before, or through a NUT server — which is how USB and serial UPS devices are reached. Both kinds can be mixed in one instance. See UPS sources.
- PVE-UPS stays a strictly read-only NUT client — no
upsmon, no shutdown scripts, no config files. The decision stays in the appliance. - The UPS test names dead thresholds: if a device reports neither the remaining runtime nor the charge, the test says so instead of leaving a threshold that silently never fires.
- A NUT server serving stale data (its driver crashed) counts as unreachable — an alarm, never a shutdown, and never mistaken for “on mains”.
- SNMPv3 with encryption now works. An authPriv user failed with “Ciphering services not available” up to and including 3.0.0 — a missing dependency, which the update installs along the way. See Troubleshooting.
- Docker deployment as an alternative to the LXC: a prebuilt image is published with every release. See Docker deployment.
- SNMP test with details per object: each RFC 1628 object with its value or the reason it is missing, so wrong credentials, a blocked port and an incomplete UPS are told apart at a glance.
- Self-test with an interval: the time of day becomes a start time, with a repeat from 15 minutes up to 24 hours. Existing installations keep their daily cadence.
- First public release on GitHub under the name PVE-UPS
(service/path names stay
pve-usvinternally). - Bilingual web UI: English (default) and German, picked automatically from the browser language; user manual in both languages.
- Event log, webhook messages and API messages uniformly English.
- E-mail notifications removed — the webhook covers notifications; existing SMTP entries are discarded automatically on update.
- Installation and updates directly via GitHub releases (install one-liner, update by package upload in the web UI; seamless from 2.x).