A complete, containerized solution for managing graceful shutdowns for UPS-protected servers that lack a direct data monitoring port. It combines a virtual NUT (Network UPS Tools) server, a power monitoring script, a central API hub for client configuration, and a modern web interface for easy management.
Many powerful, cost-effective UPS systems (like sine wave inverters with large external batteries) can power a server rack for hours but offer no way to signal a power failure to the connected devices. This project solves that problem by using a "canary in a coal mine" approach: it monitors several always-on devices (sentinel hosts) that are connected to standard, non-UPS grid power. If all of them go offline simultaneously, the application declares a power failure.
This container acts as the "brain" for a virtual NUT server, allowing standard NUT clients to perform a graceful shutdown. It also provides a central API to manage shutdown configurations for all clients and a modern web interface for easy administration.
This project is the perfect companion to the UPS_monitor client script, which is a lightweight client designed to react to the status changes generated by this Power Manager.
The logic is simple but effective:

- Monitor: A cron job inside the container runs a script (
power_manager.py) which pings a list of user-defined sentinel hosts every 15 seconds (4 checks per minute) for rapid power failure detection. - Decide:
- If at least one sentinel host is online, the script assumes grid power is OK and sets the virtual NUT server's status to
OL(Online). - If all sentinel hosts are offline, the script assumes a power failure and sets the status to
OB LB(On Battery, Low Battery).
- If at least one sentinel host is online, the script assumes grid power is OK and sets the virtual NUT server's status to
- Act & Report:
- NUT clients (
UPS_monitorscripts) periodically check the server's status. When they detectOB LB, they initiate a graceful shutdown countdown. - Clients are responsible for actively reporting their status (e.g.,
online,shutdown_pendingwith remaining time) back to the server's API on every check. - The Web GUI displays a live feed of these reported statuses. It can now show detailed states like
Online,Shutting down...,WoL sent, orStatus Staleif a client stops reporting. - When power is restored, the
power_manager.pyscript waits a configurable delay and sends Wake-on-LAN (WoL) packets to offline clients. It also sets their status toWoL sentin the dashboard, providing clear feedback on the recovery process.
- NUT clients (
- Hardware-Independent: Works with any UPS because it doesn't require a direct data connection.
- Easy Deployment: Fully containerized and managed with a single
docker-compose.ymlfile. - Modern Web GUI: Intuitive web interface for configuration and monitoring, optimized for both desktop and mobile devices.
- Centralized Management API: A lightweight REST API serves client configurations (
/config), live UPS status (/upsc), and receives client status updates (/status), centralizing all interactions. - Live Client Shutdown Monitoring: The dashboard displays a real-time countdown for each client that is preparing for shutdown, providing a clear overview of the system's state during a power outage.
- Power Outage Simulation: Manually trigger a simulated power failure from the web interface to test client shutdown procedures without physically disconnecting power.
- Scheduled Power Outage Simulation: Configure automatic start/stop times for the simulation mode for regular, hands-free testing.
- Standard-Compliant: Controls a standard NUT server, making it compatible with any NUT client (Linux, Windows, Synology DSM, etc.).
- Automated Recovery: Includes a delayed Wake-on-LAN function to automatically restart servers after stable power has returned.
- Unified Configuration: Single configuration file (
power_manager.conf) manages all aspects of the system. - Robust Logging: Includes built-in log rotation and optional, configurable forwarding to a central syslog server like Graylog.
- Email Notifications: Receive real-time alerts for critical events like power outages, power restoration, client status changes, and application errors.
- Optional Battery Monitor Integration: Read live battery data (voltage, current, state of charge, estimated runtime) from a victron-bm-webui instance, for power-state detection that doesn't depend solely on sentinel hosts.
.
├── app/ # Main application scripts
│ ├── power_manager.py # Core monitoring script
│ ├── api.py # REST API server
│ ├── web_gui.py # Web interface application
│ ├── mail_send.py # mail sending script
│ └── templates/ # HTML templates for web GUI
├── config/ # All user-editable configuration files
│ └── power_manager.conf # Main configuration file
├── cron/ # Cron job definition
├── logrotate/ # Log rotation configuration
├── rsyslog/ # (Optional) Syslog forwarding configuration
├── .gitignore # Prevents local configs from being committed
├── Dockerfile # The blueprint for the container image
├── docker-compose.yml # The easy-run file for Docker Compose
└── entrypoint.sh # The container's startup script
- A host machine that is connected to the UPS to run the container.
- Docker and Docker Compose installed on the host machine. You may find my Wiki instruction helpful: How to Install Docker Engine on Debian.
- A few reliable, always-on devices with static IPs on your network that are NOT connected to the UPS to act as sentinels.
curl,gitpackages installed on the host.
A recent Docker bug can cause DNS resolution to fail inside containers, potentially affecting features like email notifications. To mitigate this, a dns section has been added to docker-compose.yml.example with fallback public DNS servers.
If you wish to use custom DNS servers, you can configure them in your .env file:
DNS1=192.168.131.152
DNS2=192.168.131.153
Without this configuration, features relying on external network resolution (e.g., sending emails) might cease to function.
-
Clone the Repository:
git clone [https://github.com/MarekWo/UPS_Server_Docker.git](https://github.com/MarekWo/UPS_Server_Docker.git) /opt/ups-server-docker cd /opt/ups-server-docker -
Prepare Docker Compose File: Copy the example Docker Compose file. This ensures your customized file won't be overwritten by future
git pullupdates.cp docker-compose.yml.example docker-compose.yml
You can now edit
docker-compose.ymlif you need to make advanced changes, but for most users, the default is fine. -
Configure the Server: All configuration is now managed through a single file:
./config/power_manager.conf.- First, copy the example configuration file:
cp config/power_manager.conf.example config/power_manager.conf
- Then, edit
config/power_manager.confwith your specific values:SENTINEL_HOSTS: A space-separated list of IPs for your sentinel devices.WOL_DELAY_MINUTES: The time in minutes to wait after power is restored before sending WoL packets.UPS_STATE_FILE: The path to the state file used by thedummy-upsdriver. This must match theportsetting in NUT configuration.API_TOKEN: (Required) The secret token used to authenticate client requests. This value must match the token used by yourUPS_monitorclients.DEFAULT_BROADCAST_IP: The default broadcast address for Wake-on-LAN packets.SMTP Settings:SMTP_SERVERSMTP_PORTSMTP_USE_TLSSMTP_USERNAMESMTP_PASSWORDSMTP_SENDER_NAMESMTP_SENDER_EMAILSMTP_RECIPIENTS
Notification Settings:NOTIFY_POWER_FAILNOTIFY_POWER_RESTOREDNOTIFY_CLIENT_SHUTDOWNNOTIFY_CLIENT_STALENOTIFY_APP_ERRORNOTIFY_SIMULATION_MODE
[WAKE_HOST_X]: Sections defining each server to wake up. Each section requires:NAME: Descriptive name for the hostIP: IP address of the hostMAC: MAC address for Wake-on-LANBROADCAST_IP: (optional) Specific broadcast IP for this hostSHUTDOWN_DELAY_MINUTES: (optional) Makes this host a UPS client with specified shutdown delay. Use0for immediate shutdownAUTO_WOL: (optional): When set to "false" the WoL packet will not be sent to this host automatically
- First, copy the example configuration file:
-
Environment Configuration: This file (
.env) is used to pass crucial settings into the container, such as the host's IP address and your local timezone.- First, copy the example environment file if you haven't already:
cp .env.example .env
- Then, edit the
.envfile and set the following variables:TZ: Your local timezone name (e.g.,Europe/Warsaw).UPS_SERVER_HOST_IP: (Required) The IP address of the Docker host machine. This is the IP that your NUT clients will use to connect to the server (e.g.,192.168.1.10).
- First, copy the example environment file if you haven't already:
-
(Optional) Configure Syslog Forwarding: This allows you to send all internal logs to a central server like Graylog.
- First, copy the example configuration file:
cp rsyslog/custom.conf.example /etc/rsyslog.d/custom.conf
- Then, edit
rsyslog/custom.confand replace the placeholder IP address and port with your syslog server details.
- First, copy the example configuration file:
Here's an example of the unified power_manager.conf file:
# === CONFIGURATION FILE FOR power_manager.py ===
# Sentinel hosts - devices on grid power (not UPS) for monitoring
SENTINEL_HOSTS="192.168.1.11 192.168.1.12 192.168.1.13 192.168.1.14"
# Time to wait after power restoration before sending WoL packets
WOL_DELAY_MINUTES=5
# Secret token for API authentication (must match client configuration)
API_TOKEN="your_super_secret_api_token"
# Enable Power Outage Simulation mode.
# When set to "true", this will force the UPS status to "OB LB" regardless
# of the sentinel hosts' status. Useful for testing shutdown procedures.
# Valid values: "true" or "false".
POWER_SIMULATION_MODE="false"
# Path to the dummy-ups driver's state file
UPS_STATE_FILE=/var/run/nut/virtual.device
# Default broadcast address for WoL packets
DEFAULT_BROADCAST_IP=192.168.1.255
# === SMTP NOTIFICATIONS ===
SMTP_SERVER="smtp.example.com"
SMTP_PORT="587"
SMTP_USE_TLS="auto"
SMTP_USER="user@example.com"
SMTP_PASSWORD="your_password"
SMTP_SENDER_NAME="UPS Server"
SMTP_SENDER_EMAIL="ups@example.com"
SMTP_RECIPIENTS="admin@example.com"
# === NOTIFICATION SETTINGS ===
# Enable or disable notifications for specific events. Valid values: "true" or "false".
NOTIFY_POWER_FAIL="true"
NOTIFY_POWER_RESTORED="true"
NOTIFY_CLIENT_SHUTDOWN="false"
NOTIFY_CLIENT_STALE="true"
NOTIFY_APP_ERROR="true"
NOTIFY_SIMULATION_MODE="true"
# === WAKE-ON-LAN HOST DEFINITIONS ===
# UPS Client with shutdown delay
[WAKE_HOST_1]
NAME=Synology NAS
IP=192.168.1.12
MAC=00:11:32:f8:af:9f
BROADCAST_IP=192.168.1.255
SHUTDOWN_DELAY_MINUTES=10
# Another UPS Client
[WAKE_HOST_2]
NAME=Proxmox Server
IP=192.168.1.13
MAC=00:11:32:2c:31:42
SHUTDOWN_DELAY_MINUTES=15
# UPS Client with immediate shutdown (delay=0)
[WAKE_HOST_3]
NAME=Low-Battery UPS Host
IP=192.168.1.14
MAC=00:11:32:bb:cc:dd
SHUTDOWN_DELAY_MINUTES=0
# WoL-only host (no UPS client functionality)
[WAKE_HOST_4]
NAME=File Server
IP=192.168.1.15
MAC=00:11:32:aa:bb:cc
AUTO_WOL="false"
# === POWER OUTAGE SIMULATION SCHEDULES ===
# [SCHEDULE_1]
# NAME="Weekly Test Shutdown"
# TYPE="recurring"
# DAY_OF_WEEK="friday"
# TIME="23:00"
# ACTION="start"
# ENABLED="true"The SMTP_USE_TLS option provides explicit control over STARTTLS usage:
auto(default): Uses STARTTLS on all ports except 26 (legacy behavior)true: Always attempts STARTTLS (except on port 465 which uses SSL)false: Never uses STARTTLS (for servers that don't support it)
The Wake-on-LAN (WoL) feature requires the container to send special "magic packets" to your network's broadcast address. Docker's default, sandboxed network mode prevents containers from sending broadcast packets to the physical LAN, which means the WoL feature will not work out of the box.
To enable WoL, you must allow the container to share the host's network stack.
The simplest way to enable WoL is to add the network_mode: host directive to your docker-compose.yml file. This gives the container direct access to the host's network interfaces.
Your docker-compose.yml should be modified to include this line:
services:
ups-server:
build: .
container_name: ups-server
restart: unless-stopped
network_mode: host # This line enables WoL functionality
# NOTE: The 'ports' section is ignored in host mode and can be removed.
# ports:
# - "3493:3493"
# - "5000:5000"
# - "80:80"
# ... rest of your configurationSecurity Note: Using network_mode: host removes network isolation between the container and the host. The container gains access to all of the host's network interfaces and can bind to any port. While this application is built to be trustworthy, you should always be aware of the security implications of this setting.
If you do not need the Wake-on-LAN feature or are not comfortable with using host network mode, simply do not add the network_mode: host line.
In this case, the NUT server and API will function correctly for monitoring and shutting down clients, but the ability to automatically wake them up will be disabled.
Once configured, starting the server is a single command from the project's root directory:
docker compose up --build -d--build: Only needed the first time or after changing theDockerfileor application scripts.-d: Runs the container in the background (detached mode).
Managing the Container:
- View Logs:
docker compose logs -f
- Stop the Container:
docker compose down
- Restart the Container:
docker compose restart
You can also monitor the power_manager.log file in the ./logs directory, which is created automatically on your host.
The UPS Server includes a modern, responsive web interface for easy configuration and monitoring.
After starting the container, the web interface will be available at:
http://<UPS_SERVER_IP>
For example, if your UPS server host has IP 192.168.1.10:
http://192.168.1.10
- Dashboard: Real-time monitoring of system status, sentinel hosts, and UPS clients. Shows a live countdown for clients that are shutting down.
- Configuration Management: Easy-to-use forms for managing all system settings.
- Live Status Updates: Automatic refresh of host statuses every 5 seconds.
- Mobile Optimized: Responsive design that works on all devices.
- One-Click Wake-on-LAN: Send WoL packets directly from the interface.
For detailed Web GUI documentation, see WEB_GUI_README.md.
The server provides a REST API for client configuration and status monitoring. All endpoints require an Authorization header with a bearer token. The API token is now configured in config/power_manager.conf.
Example Request:
curl -H "Authorization: Bearer <your_secret_token>" http://<server_ip>:5000/upscThis endpoint provides client-specific shutdown configuration. The client's IP address is used to look up its settings in the WAKE_HOST_X sections of power_manager.conf.
- Query Parameter:
ip=<client_ip>(optional, falls back to request source IP). - Returns: A JSON object with the client's configuration, including the dynamically generated
UPS_NAMEandSHUTDOWN_DELAY_MINUTES.
Example Response (/config?ip=192.168.1.12):
{
"SHUTDOWN_DELAY_MINUTES": "10",
"UPS_NAME": "ups@192.168.1.10"
}This endpoint allows UPS clients to report their current status back to the server. This is used to display the shutdown countdown in the Web GUI.
- Body: A JSON object containing the client's status.
- Example Payload:
{ "ip": "192.168.1.12", "status": "shutdown_pending", "remaining_seconds": 245, "shutdown_delay": 5 }
Returns the latest battery reading from the Victron monitor, as captured by power_manager.py. See Battery Monitor Integration.
- Returns: The battery section of the current power state. When the integration is disabled, unreachable, or the reading is stale,
availableisfalseanderrorexplains why.
Example Response:
{
"enabled": true,
"available": true,
"url": "http://localhost:8088",
"simulation": false,
"voltage": 13.76,
"current": 0.105,
"power": 1.44,
"soc": 100.0,
"remaining_mins": null,
"temperature": 28.0,
"ac_power": true,
"ac_power_since": "2026-08-01T09:14:00+00:00",
"ac_power_inferred": false,
"connected": true,
"last_update": "2026-08-03T10:47:56.760037+00:00",
"data_age_seconds": 3.2,
"updated_at": "2026-08-03T10:47:59Z"
}This endpoint provides live status information from the NUT server, equivalent to running the upsc command locally, but with clean, nested JSON output.
- Returns: A nested JSON object containing all available UPS variables, plus additional simulation status information. This is ideal for monitoring dashboards or advanced client-side logic.
- Simulation Detection: The response includes a
simulationfield in theupssection that indicates whether the current power outage status is real (false) or simulated (true).
Example Response:
{
"device": {
"mfr": "Dummy Manufacturer",
"model": "Dummy UPS",
"type": "ups"
},
"driver": {
"name": "dummy-ups",
"parameter": {
"mode": "dummy",
"pollinterval": 2,
"port": "/var/run/nut/virtual.device"
},
"version": "2.8.0",
"version.internal": 0.15
},
"ups": {
"mfr": "Dummy Manufacturer",
"model": "Dummy UPS",
"status": "OL",
"simulation": false
}
}Per-client status: when the battery monitor integration is enabled, ups.status is tailored to the calling client — identified by the ip query parameter, then X-Forwarded-For, then the source address. A host configured with battery thresholds is told OB LB when its own thresholds are crossed, not when the site-wide sentinel check trips. Unknown addresses get the global status, exactly as before.
ups.status keeps carrying only OL or OB LB, because that is the contract UPS_monitor v4.4.0 understands. The richer picture arrives in additive fields that older clients simply ignore:
{
"ups": {
"status": "OL",
"status_detail": "OB",
"simulation": false,
"power_source": "battery",
"shutdown_reason": "on battery, thresholds not reached (SoC 87.0% > 40.0%, ~142 min left)",
"decision_mode": "enforce"
},
"battery": {
"charge": 87.0,
"voltage": 12.41,
"current": -28.5,
"runtime": 8520,
"connected": true
}
}status_detail is the true NUT state (OL, OB or OB LB) — so "status": "OL" with "status_detail": "OB" means "running on battery, but this host still has margin". The battery.* keys use standard NUT variable names.
Simulation Status Field:
The simulation field in the ups section indicates the current power outage simulation status:
false: Normal operation - UPS status reflects real power conditionstrue: Simulation mode active - UPS status is artificially set for testing purposes
This field is read from the POWER_SIMULATION_MODE parameter in power_manager.conf and allows UPS clients to distinguish between real power outages and simulated ones for testing.
The sentinel-host approach answers one question — "are the devices on grid power still reachable?" — and infers a power outage from the answer. That works, but it has two blind spots:
- A network failure looks exactly like a power failure. If the router or switch feeding your sentinels reboots, all of them go unreachable at once and the server declares an outage while mains is perfectly fine.
- It knows nothing about remaining capacity. Once an outage is declared, the only differentiator between clients is a fixed timer.
If you have a Victron battery monitor (e.g. a BMV-712 Smart) on the UPS battery, victron-bm-webui can supply both missing pieces: a direct reading of whether the battery is being charged or discharged, and the actual state of charge.
This integration is entirely optional. With BATTERY_ENABLED="false" (the default) nothing about the server's behaviour changes. When enabled, it is still never load-bearing: if the battery service is unreachable, its BLE link is down, or its readings go stale, the server logs a warning and silently falls back to sentinel-based detection.
-
Install and run victron-bm-webui — ideally on the same Docker host, so it is reachable at
http://localhost:8088. -
Verify it is serving data, including the mains state:
curl -s http://localhost:8088/api/v1/status | jq '.ac_power, .voltage, .soc'
-
In the Web GUI, open Configuration → Battery Monitor (Victron), tick Enable Battery Monitor Integration, set the URL, and save.
-
Click Test Battery Monitor to confirm the server can read it, then check the Battery Monitor panel on the dashboard.
Equivalent settings in config/power_manager.conf:
BATTERY_ENABLED="true"
BATTERY_API_URL="http://localhost:8088"
BATTERY_API_TIMEOUT="5" # HTTP timeout, seconds
BATTERY_MAX_DATA_AGE="60" # Readings older than this are rejected as staleOlder victron-bm-webui versions do not report ac_power themselves. The server detects this and derives the mains state locally from voltage and current, using BATTERY_AC_FALLBACK_VOLTAGE, BATTERY_AC_DISCHARGE_CURRENT and BATTERY_AC_CHARGE_CURRENT; the dashboard marks such readings as Inferred. Upgrading victron-bm-webui is the better fix, since it applies hysteresis and debounce across readings rather than judging each one in isolation.
Which source decides a host's power state is configured per host, because hosts differ in how much they can afford a wrong answer:
POWER_SOURCE |
Behaviour |
|---|---|
sentinel (default) |
Ping-based detection only — exactly as before. |
battery |
The Victron monitor decides. Falls back to sentinels automatically whenever battery data is missing or stale. |
both |
An outage is only declared when both sources agree. The most conservative option, and immune to network faults. |
Any host left at sentinel behaves identically to how it did before this feature existed.
Once a host is on battery or both, its shutdown point is set by thresholds rather than by a shared timer:
[WAKE_HOST_5]
NAME=Media Server
IP=192.168.1.17
MAC=00:11:32:11:22:33
SHUTDOWN_DELAY_MINUTES=2
POWER_SOURCE=battery
SHUTDOWN_SOC=40 # Give up early - this host is not critical
WOL_MIN_SOC=90 # Don't wake it until the battery has recovered
[WAKE_HOST_6]
NAME=Critical Server
IP=192.168.1.18
MAC=00:11:32:44:55:66
SHUTDOWN_DELAY_MINUTES=0
POWER_SOURCE=both
SHUTDOWN_SOC=15 # Run the battery much further down
CRITICAL_VOLTAGE=11.0
MIN_RUNTIME_MINUTES=10
WOL_MIN_SOC=95Thresholds are evaluated in order of how conclusive each signal is: CRITICAL_VOLTAGE, then SHUTDOWN_SOC, then SHUTDOWN_VOLTAGE, then MIN_RUNTIME_MINUTES. Any threshold left unset falls back to the corresponding BATTERY_DEFAULT_* value, and then to a built-in default.
Set the voltage thresholds low. They are backstops for when state of charge is unavailable or wrong —
SHUTDOWN_SOCshould be the rule that actually fires. Battery voltage collapses hard the moment load moves onto it: a ~100 Ah bank measured 13.78 V at rest and 12.23 V under a 56 A load while still at 99 % SoC. Set the threshold too close to that and it trips around half charge, for every host in the same cycle — which throws away the per-host thresholds entirely. Measure your own bank under its real load rather than trusting the defaults.
Note on
SHUTDOWN_DELAY_MINUTES: with a battery source the thresholds already decide when to shut down, so the client-side timer becomes an additional grace period stacked on top. Values of0–2are usually what you want for battery-driven hosts.
Handing shutdown decisions to a new data source on a live system is worth doing carefully, so the integration starts in observe mode:
BATTERY_DECISION_MODE="observe" # or "enforce"In observe mode the battery rules run in full, appear on the dashboard, and are written to the log — but clients keep being told exactly what the sentinel hosts imply. Nothing shuts down differently. Wherever the two sources would have disagreed, the log says so:
SOURCE DISAGREEMENT Media Server (192.168.1.17): sentinels imply OB LB,
battery implies OL - mains present (13.76V, +0.10A, SoC 100.0%) (mode=observe)
Leave it in observe mode for a few days, confirm the battery monitor tracks reality, then switch to enforce. A sensible rollout is to enable POWER_SOURCE=battery one host at a time, starting with the least critical one.
Power outage simulation is unaffected by the decision mode — a simulated outage still exercises the real shutdown path in both modes.
A scheduled simulation holds the site at OB LB for its whole window, so a
genuine outage starting underneath one is not a state transition and cannot be
detected by watching the state machine. The sentinel hosts are therefore polled
throughout a simulation, and a real failure is judged on that reading alone:
- the simulation is interrupted immediately and
POWER_SIMULATION_MODEis cleared, so the shutdown decisions that follow are the real ones; - the outage alert is sent even though the state machine was already sitting in
POWER_FAIL, and exactly once per outage; - if mains returns while the window is still open, the simulation resumes and the restoration is still announced - the "power is back" mail is not swallowed by the simulation notification class, which is usually switched off;
- the Wake-on-LAN countdown started by that restoration is re-checked against the live sentinel reading before it fires, so a grid that drops again during the delay cannot wake a host into a running outage.
Windows that cross midnight (21:00 to 06:00, the usual overnight test) are
supported: the start and stop schedules are paired by DAY_OF_WEEK, and the
hours after midnight count as part of the day the window opened on.
Sentinel hosts answer "is there grid power in the building". That is a proxy
for the question that actually matters - "is the UPS being fed" - and the two
come apart whenever the fault is confined to the UPS's own circuit. A tripped
breaker or RCD is enough: the grid is fine, every sentinel answers, the site
status never leaves OL, and the ordinary outage alert never fires. Meanwhile
the bank drains and the hosts shut down one by one on their state of charge
thresholds, unannounced.
The battery monitor is the only source measuring the right thing, so a separate alert hangs off it alone:
BATTERY-ONLY OUTAGE: mains lost according to the battery for 8 consecutive
checks while 3 of 3 sentinel hosts are still reachable - the UPS feed itself
looks dead.
It is debounced by BATTERY_ONLY_OUTAGE_CYCLES (default 8, two minutes at
the 15s cadence) because the same disagreement appears harmlessly at the end of
every ordinary outage: the sentinels boot the moment the grid returns, while
the battery monitor still needs a few seconds of charge current to call it.
That window was two cycles wide on the reference installation.
The alert is announced once per event and cleared with an "UPS Mains Restored" notice when the battery is on mains again.
POWER_SOURCE=both shuts a host down only when the sentinels and the battery
agree that mains is gone. That is the point of the mode, but it has a sharp
edge: if a single sentinel survives a real outage - one quietly moved onto
protected power, or simply still answering pings while hung - then every both
host stays online and its state of charge and voltage thresholds are never
consulted at all. Left alone, those hosts run until the bank is flat.
Critical voltage is the exception. It is the last honest warning before the inverter drops out, so it overrides the disagreement and shuts the host down anyway:
VERDICT PBS1 (192.168.1.34): serving OB LB - battery voltage 10.90V at or below
critical 11.00V - shutting down despite a sentinel host still being reachable
Nothing else overrides it, or the mode would not mean anything. Note how late
this fires: on a 12V lead-acid bank under load, CRITICAL_VOLTAGE=11.0 is
somewhere below 15% state of charge. It is a net against a flat bank, not a
substitute for sentinels that actually track the grid.
A battery monitor's own runtime estimate answers a subtly different question
from the one a UPS needs answered. A Victron BMV counts its time-to-go down to
the Discharge floor configured in the gauge, and reports nothing at all
once the bank is past it. That floor has nothing to do with SHUTDOWN_SOC,
which is where this server actually starts shutting hosts down.
On the reference installation the floor is 50% and SHUTDOWN_SOC is 40%. During
a 90 minute crash test the dashboard therefore read 7 min while 25 minutes of
margin remained, and went blank with 16 minutes still to go - at a perfectly
healthy 12.0V.
Set BATTERY_CAPACITY_AH to the usable capacity of the bank and the figure is
computed here instead, from the state of charge and the present draw, measured
to the threshold that governs the shutdown:
BATTERY_CAPACITY_AH="85" # 0 = defer to the monitor's own estimateThat figure is what the dashboard shows (labelled Runtime to 40%, so it is
clear which finish line it counts to), what clients are served as NUT's
battery.runtime, and what MIN_RUNTIME_MINUTES is compared against. Without
it, all three fall back to the monitor's estimate and behave as before - which
also means MIN_RUNTIME_MINUTES fires early and stops working below the
gauge's floor, so measure the capacity before relying on that rule.
The site-wide figure is measured to the lowest SHUTDOWN_SOC among
battery-sourced hosts - the last host standing. That is the only finish line
that stays useful for a whole outage: with the thresholds spread out to stagger
the shutdowns, measuring to the first one instead makes the tile read zero
within minutes and sit there for the rest of the outage. When each individual
host goes down is per-host information, and the client table already shows it.
If a poll comes back empty, a battery-sourced host is handed the sentinel
verdict instead. That is deliberately fail-safe - if the battery monitor dies
during a genuine outage, the hosts must still come down - but during an outage
the sentinel verdict is OB LB, so a single missed poll is enough to shut a
host down on the spot.
BATTERY_FALLBACK_GRACE_CYCLES (default 4, roughly a minute at the 15s poll)
rides out short gaps. Inside the window each battery-sourced host keeps the
verdict it was last given and the log says so:
HOLDING Synology Chomik (192.168.1.16): serving OL - battery data missing for 2 of 4 allowed polls
Holding the previous verdict rather than forcing OL is what makes this safe in
both directions: a host already counting down to shutdown keeps counting down.
Once the window is exhausted the ordinary fallback applies. Set the value to 0
to fall back on the first miss.
This matters most when the gap is caused by the UPS server itself rather than by the battery: a container restart, or the host being suspended, takes the battery API away for a few seconds while the sentinels still read as offline.
Waking servers onto a battery that is still nearly empty just means shutting them down again minutes later. Set WOL_MIN_SOC on a host and it will not be woken until the battery reaches that charge and is actually charging (positive current):
WOL_MIN_SOC=95
WOL_MAX_WAIT_MINUTES="240" # global safety net, 0 disables the limitDeferred hosts show as Waiting for charge on the dashboard and are retried every 15 seconds. WOL_MAX_WAIT_MINUTES is the escape hatch: after that long, they are woken regardless, so a failed battery monitor cannot keep them asleep indefinitely.
The gate applies to every host with WOL_MIN_SOC set, including hosts left on POWER_SOURCE=sentinel. It is about the state of the battery, not about which source decides shutdowns — and in practice the hungriest machine is often exactly the one kept on the sentinel source, because it is the cheapest to shed early. That makes it the one that most needs holding back until the bank has recovered. Setting the thresholds in reverse order of the shutdown sequence brings the most valuable host back first and the greediest one last.
Battery voltage is deliberately not used as a wake-up gate. Right after mains returns, the charger pushes voltage to 14.2–14.4 V even at 40% charge — so it would wave through exactly the case this is meant to prevent. State of charge is the honest signal.
The POWER_FAIL → POWER_RESTORED state machine that normally arms Wake-on-LAN is driven entirely by sentinel pings, so it never sees an outage that only the battery monitor noticed. A host shut down by its own SHUTDOWN_SOC — while the sentinels stayed reachable and every other host kept running — therefore needs its own wake-up path, and gets one.
Each battery-sourced host is tracked individually: once its verdict returns to OL, it is woken after WOL_DELAY_MINUTES, subject to the same WOL_MIN_SOC gate and WOL_MAX_WAIT_MINUTES safety net as above. The tracker stands down entirely whenever the sentinel-driven cycle has anything to do, so the two never both own the same wake-up or send duplicate mail. State lives in /var/run/nut/battery_wol.json.
This matters most in exactly the situation the integration exists for: sentinels reachable (they are on a different circuit, or behind a UPS-backed switch) while the battery genuinely drains. Without it, hosts shut down correctly and then stay off with nothing logged to explain why.
Battery simulation overlays fixed values on top of the real reading, so thresholds can be exercised on demand. It works independently of Power Outage Simulation, and the dashboard shows a Simulated badge whenever it is active.
BATTERY_SIMULATION="true"
BATTERY_SIM_AC_POWER="false" # Pretend mains is gone
BATTERY_SIM_SOC="24" # Pretend the battery is at 24%
BATTERY_SIM_VOLTAGE="" # Empty = keep the real value
BATTERY_SIM_CURRENT=""
BATTERY_SIM_REMAINING=""The container provides the following services:
- Port 80: Web GUI interface
- Port 5000: REST API for client configuration
- Port 3493: NUT server for UPS clients
To update the application to the latest version from GitHub, follow these steps. This method is robust and will discard any accidental local changes, ensuring a clean update.
-
Navigate to the application directory
cd /opt/ups-server-dockerNote:
sudois likely required for the following commands if you cloned the repository into a system directory like/opt. -
Fetch the latest version from the repository This command downloads the latest updates from GitHub.
sudo git fetch origin
-
Reset your local files to match the latest version This command will discard any local changes (like modified permissions or accidental edits) and force your local copy to match the official version.
sudo git reset --hard origin/main
-
Rebuild and restart the container This applies the updates and restarts the application.
sudo docker compose up --build -d
Docker Compose will intelligently rebuild only what's necessary and restart the container. Your configuration files in the
./configdirectory will be preserved.
If you're upgrading from a version that used separate upshub.conf configuration:
-
Backup your current configuration:
cp config/power_manager.conf config/power_manager.conf.backup cp config/upshub.conf config/upshub.conf.backup
-
Migrate UPS client settings: Add
SHUTDOWN_DELAY_MINUTESparameter to appropriate[WAKE_HOST_X]sections inpower_manager.confbased on your oldupshub.confsettings. -
Remove old configuration:
rm config/upshub.conf
-
Update and restart:
docker compose up --build -d
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
If you have a suggestion that would make this project better, please feel free to share it. The preferred way to do so is by starting a conversation in the Discussions section of the repository.
If you would like to contribute with your code, please fork the repo and create a pull request.
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
Thank you for helping make this tool better!
Have you found a bug or have an idea for a new feature? I would love to hear from you!
To ensure that ideas are well-discussed and bugs are properly triaged, this project uses GitHub Discussions as the first step for all new reports.
How to submit an issue or idea:
- Go to the Discussions tab and open a new discussion in the "Ideas" category.
- Provide a clear title and a detailed description of the issue or your suggestion. If you're reporting a bug, please include:
- Steps to reproduce the behavior.
- What you expected to happen.
- What actually happened (screenshots are welcome!).
- Your environment details (e.g., Docker version, host OS).
- Engage in the discussion. I will review your post and may ask follow-up questions.
- From Discussion to Issue. If the report is confirmed as a bug or the feature is considered for implementation, I will create an official
Issuedirectly from your discussion thread to track its progress.
This process helps keep the official issue tracker clean and focused on actionable items. Thank you for your understanding and cooperation!
This project is licensed under the MIT License - see the LICENSE file for details.
