Skip to content

Repository files navigation

UPS Power Management Server (Docker Edition)

License: MIT

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.

About This Project

▶️ Play Podcast about UPS Server

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.


How It Works

The logic is simple but effective: Dashboard

  1. 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.
  2. 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).
  3. Act & Report:
    • NUT clients (UPS_monitor scripts) periodically check the server's status. When they detect OB LB, they initiate a graceful shutdown countdown.
    • Clients are responsible for actively reporting their status (e.g., online, shutdown_pending with 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, or Status Stale if a client stops reporting.
    • When power is restored, the power_manager.py script waits a configurable delay and sends Wake-on-LAN (WoL) packets to offline clients. It also sets their status to WoL sent in the dashboard, providing clear feedback on the recovery process.

Features

  • 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.yml file.
  • 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.

Project Structure


.
├── 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


Prerequisites

  • 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, git packages installed on the host.

Important Note on Docker DNS Resolution (Workaround for recent Docker bug)

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.


Installation and Configuration

  1. 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
  2. Prepare Docker Compose File: Copy the example Docker Compose file. This ensures your customized file won't be overwritten by future git pull updates.

    cp docker-compose.yml.example docker-compose.yml

    You can now edit docker-compose.yml if you need to make advanced changes, but for most users, the default is fine.

  3. 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.conf with 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 the dummy-ups driver. This must match the port setting in NUT configuration.
      • API_TOKEN: (Required) The secret token used to authenticate client requests. This value must match the token used by your UPS_monitor clients.
      • DEFAULT_BROADCAST_IP: The default broadcast address for Wake-on-LAN packets.
      • SMTP Settings:
        • SMTP_SERVER
        • SMTP_PORT
        • SMTP_USE_TLS
        • SMTP_USERNAME
        • SMTP_PASSWORD
        • SMTP_SENDER_NAME
        • SMTP_SENDER_EMAIL
        • SMTP_RECIPIENTS
      • Notification Settings:
        • NOTIFY_POWER_FAIL
        • NOTIFY_POWER_RESTORED
        • NOTIFY_CLIENT_SHUTDOWN
        • NOTIFY_CLIENT_STALE
        • NOTIFY_APP_ERROR
        • NOTIFY_SIMULATION_MODE
      • [WAKE_HOST_X]: Sections defining each server to wake up. Each section requires:
        • NAME: Descriptive name for the host
        • IP: IP address of the host
        • MAC: MAC address for Wake-on-LAN
        • BROADCAST_IP: (optional) Specific broadcast IP for this host
        • SHUTDOWN_DELAY_MINUTES: (optional) Makes this host a UPS client with specified shutdown delay. Use 0 for immediate shutdown
        • AUTO_WOL: (optional): When set to "false" the WoL packet will not be sent to this host automatically
  4. 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 .env file 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).
  5. (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.conf and replace the placeholder IP address and port with your syslog server details.

Example Configuration

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)

🔌 Network Configuration for Wake-on-LAN (WoL)

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.

Option 1: Full Functionality with host Network Mode (Recommended for WoL)

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 configuration

Security 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.

Option 2: Standard Network Isolation (WoL Disabled)

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.


Usage

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 the Dockerfile or 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.


Web Interface

The UPS Server includes a modern, responsive web interface for easy configuration and monitoring.

Accessing the Web GUI

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

Web GUI Features

  • 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.


API Endpoints

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/upsc

GET /config

This 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_NAME and SHUTDOWN_DELAY_MINUTES.

Example Response (/config?ip=192.168.1.12):

{
  "SHUTDOWN_DELAY_MINUTES": "10",
  "UPS_NAME": "ups@192.168.1.10"
}

POST /status

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
    }

GET /battery

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, available is false and error explains 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"
}

GET /upsc

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 simulation field in the ups section 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 conditions
  • true: 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.


Battery Monitor Integration (Optional)

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:

  1. 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.
  2. 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.

Setup

  1. Install and run victron-bm-webui — ideally on the same Docker host, so it is reachable at http://localhost:8088.

  2. Verify it is serving data, including the mains state:

    curl -s http://localhost:8088/api/v1/status | jq '.ac_power, .voltage, .soc'
  3. In the Web GUI, open Configuration → Battery Monitor (Victron), tick Enable Battery Monitor Integration, set the URL, and save.

  4. 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 stale

Older 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.

Choosing a power source per host

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=95

Thresholds 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_SOC should 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 of 0–2 are usually what you want for battery-driven hosts.

Observe mode — try it before trusting it

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.

Real outages during a simulation window

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_MODE is 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.

The outage the sentinel hosts cannot see

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.

The one thing both will not wait for

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.

How much runtime is actually left

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 estimate

That 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.

When the battery monitor goes quiet

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.

Holding back Wake-on-LAN until the battery has recovered

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 limit

Deferred 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.

Waking hosts the battery shut down on its own

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.

Testing without draining the battery

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=""

Services and Ports

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

Updating

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.

  1. Navigate to the application directory

    cd /opt/ups-server-docker

    Note: sudo is likely required for the following commands if you cloned the repository into a system directory like /opt.

  2. Fetch the latest version from the repository This command downloads the latest updates from GitHub.

    sudo git fetch origin
  3. 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
  4. 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 ./config directory will be preserved.


Migration from the Hub Edition (from before August 15th, 2025)

If you're upgrading from a version that used separate upshub.conf configuration:

  1. Backup your current configuration:

    cp config/power_manager.conf config/power_manager.conf.backup
    cp config/upshub.conf config/upshub.conf.backup
  2. Migrate UPS client settings: Add SHUTDOWN_DELAY_MINUTES parameter to appropriate [WAKE_HOST_X] sections in power_manager.conf based on your old upshub.conf settings.

  3. Remove old configuration:

    rm config/upshub.conf
  4. Update and restart:

    docker compose up --build -d

🤝 Contributing

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.

  1. Fork the Project
  2. Create your Feature Branch (git checkout -b feature/AmazingFeature)
  3. Commit your Changes (git commit -m 'Add some AmazingFeature')
  4. Push to the Branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

Thank you for helping make this tool better!

🐛 Reporting Issues and Suggesting Features

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:

  1. Go to the Discussions tab and open a new discussion in the "Ideas" category.
  2. 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).
  3. Engage in the discussion. I will review your post and may ask follow-up questions.
  4. From Discussion to Issue. If the report is confirmed as a bug or the feature is considered for implementation, I will create an official Issue directly 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!

🎬 BONUS: See the UPS Server Presentation on YouTube

See the video on YouTube


License

This project is licensed under the MIT License - see the LICENSE file for details.

About

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.

Topics

Resources

Stars

26 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages