Sign inSign up

cplieger/docker-nut-upsd

By cplieger

Updated about 4 hours ago

Monitor your UPS and let networked machines shut down gracefully during power outages

Image
Networking
Monitoring & observability
1

7.9K

cplieger/docker-nut-upsd repository overview

docker-nut-upsd

Monitor your UPS and let networked machines shut down gracefully during power outages.

What it does

Monitors your UPS (uninterruptible power supply) and exposes its status over the network so other machines can shut down gracefully during a power outage.

The container runs the Network UPS Tools (NUT) upsd daemon in Alpine Linux. The entrypoint script generates all NUT configuration files (ups.conf, upsd.conf, upsd.users, upsmon.conf) from environment variables at startup.

  • Supports USB HID, Modbus, and SNMP UPS devices
  • Exposes the standard NUT protocol on port 3493 for network clients
  • TLS (NUT STARTTLS) on by default: a self-signed certificate is generated at first boot, or mount your own at /etc/nut/upsd.pem; legacy cleartext clients keep working (see TLS)
  • Optional host shutdown via D-Bus when the UPS reaches critical battery (SHUTDOWN_ON_BATTERY_CRITICAL=true)
  • Recovers from lost UPS communications without a restart: a built-in comms watchdog restarts the driver after sustained stale data on any transport - USB re-enumeration is the common case (see USB hotplug & comms recovery)
  • Custom config override: mount /etc/nut/{ups.conf,upsd.conf,upsd.users,upsmon.conf}.user to bypass env-var generation. You then own every directive in that file, including the ones the rest of the image reads:
    • ups.conf.user: keep the section name ([...]) equal to UPS_NAME and its driver directive equal to UPS_DRIVER. The healthcheck, generated upsmon.conf MONITOR line, and comms watchdog use the section name. NUT names the PID file from the driver directive and section, while the startup gate waits for /var/run/nut/$UPS_DRIVER-$UPS_NAME.pid. Either mismatch is fatal at boot: the container logs UPS driver did not confirm a live PID for the expected binary in time and exits about five seconds in, so your restart policy brings it straight back to the same state. A mounted ups.conf.user also makes LOWBATT_PERCENT and LOWBATT_RUNTIME inert
    • ups.conf.user: on a serial Modbus driver (apc_modbus, generic_modbus, adelsystem_cbi), keep the baud rate to one libmodbus's classic _get_termios_speed table maps — every standard POSIX B* rate is there, 110 through 115200 and beyond (9600 is each of those drivers' own default), but a non-standard rate such as 14400 or 28800 is not. libmodbus is built here without termios2 support, which does not compile on Alpine/musl, so baudrate 14400 or ser_baud_rate 28800 is accepted, silently opened at 9600, and the driver never reaches the device: what you see is a comms failure, with nothing naming the baud rate
    • ups.conf.user: keep the driver's worst-case start inside 90s, the outer bound the entrypoint puts on upsdrvctl start; NUT's own maxstartdelay defaults to 75s per driver and maxretry to 1 attempt (ups.conf), so raising either — maxretry 2 alone allows up to 75 + 5 + 75 = 155s — can push a configuration NUT considers healthy past that bound, and the container logs upsdrvctl start failed or timed out at boot and exits, leaving your restart policy to loop it
    • upsd.conf.user: keep LISTEN on API_ADDRESS and API_PORT, where those same probes look; a divergent LISTEN fails every one of them against a correctly-serving upsd, the container exits after about a minute of failed probes, and your restart policy brings it back into the same state
    • upsmon.conf.user: keep a SHUTDOWNCMD line, or a forced shutdown takes no action on the host even with SHUTDOWN_ON_BATTERY_CRITICAL=true; upsmon prints Warning: no shutdown command defined! once at startup
    • upsmon.conf.user: keep POWERDOWNFLAG /var/run/nut-secrets/killpower at that exact path, or the comms watchdog cannot stand down during a real host poweroff. A driver bounce causes a bounded FSD blackout for networked secondaries and skips the HOSTSYNC wait because NUT v2.8.5 reads the secondary-login count as zero after the upsd-side failure. The boot-time stale-flag clear also stops matching
    • upsmon.conf.user: keep the NOTIFYCMD line and the EXEC notify flags (see Alerting)
  • Configurable low-battery thresholds
  • Clean signal handling: SIGTERM gracefully stops all NUT services

Pull

docker pull cplieger/docker-nut-upsd:latest

Also published to ghcr.io/cplieger/docker-nut-upsd with identical images and tags. Release versions are tagged vX.Y.Z alongside latest.

Quick start

# Example compose for docker-nut-upsd. See the README for all configuration options and hardening.
services:
  nut-upsd:
    image: ghcr.io/cplieger/docker-nut-upsd:latest
    container_name: nut-upsd
    restart: unless-stopped

    environment:
      UPS_NAME: "ups"
      UPS_DESC: "My UPS"
      UPS_DRIVER: "usbhid-ups"  # see NUT hardware compatibility list
      UPS_PORT: "auto"  # auto = USB auto-detection
      API_USER: "monuser"
      API_PASSWORD: "secret"  # change this

    ports:
      - "3493:3493"

    # USB hotplug: bind the bus LIVE (not via devices:) plus the USB-major cgroup rule, so a
    # UPS that re-enumerates to a new node stays reachable without a recreate. See README.
    device_cgroup_rules:
      - "c 189:* rmw"
    volumes:
      - "/dev/bus/usb:/dev/bus/usb"

Documentation

License

Apache-2.0. See LICENSE.

The image carries the license text of every bundled component under /usr/share/licenses/. The Alpine packages in the image ship no license file upstream, so their license texts are kept under licenses/ in this repository and copied in.

The image packages Network UPS Tools (GPL-2.0-or-later), compiled from the release tarball the Dockerfile fetches at the version its NUT_VERSION argument pins (https://github.com/networkupstools/nut/releases/download/<version>/nut-<version>.tar.gz), and links libmodbus and Net-SNMP, each compiled the same way from the tarball its own version argument pins. Each component's own license text is in that tree.

The build applies checked-in backports of upstream NUT source, so those files stay GPL-2.0-or-later:

  • patches/cve-2026-54161-notifycmd-execvp.patch backports upstream commit ecf98e7542e4ae2b62b211622ee26989274b2220.
  • patches/libusb-exit-reconnect-deadlock.patch backports upstream commit bfbba15928aa6a91b3e4b8943e0cad16199d9d48.
  • patches/libusb-rdlens-oob-read.patch backports upstream commit edc06fb39435b892d5daeec53cb4845cb12d1d50.
  • patches/richcomm-libusb-context-reopen.patch backports upstream commit ce2364e2b1e79406248be50b01a031db67c1c9fd.

This repository's Dockerfile and those patch files are the complete recipe for the NUT, libmodbus and Net-SNMP binaries the image ships: the corresponding source is the upstream tarball each pin names, with the patches above applied.

Tag summary

Content type

Image

Digest

sha256:c4c81469e

Size

25.1 MB

Last updated

about 4 hours ago

docker pull cplieger/docker-nut-upsd