# Run upsmon as a NUT secondary on Fedora and Fedora Atomic

> Install nut-client on Fedora or Bazzite, configure upsmon as a read-only secondary of a remote NUT server, check the login, and keep it running across reboots.

Published 2026-11-02. HTML version: https://guides.tsvinc.dev/upsmon-secondary-fedora-atomic/


This installs `upsmon` on a Fedora or Fedora Atomic host as a read-only `secondary` of a remote NUT server, checks the login before anything is armed, and enables it so it starts again after a reboot.

> **Scope** — Verified on: Fedora 44 KDE Plasma Desktop and Fedora 45 Prerelease (installed, enabled, status read; reboot checked on Fedora 44); Fedora 44 Server and Bazzite 44.20261012.1 (installed, configured, login checked, service left disabled). nut-client / upsmon 2.8.5 (2026-10-16, re-checked 2026-10-17). The server side was my own NUT server (nut-observer), not `upsd`; the steps are the stock client side.
> **Not verified:** the shutdown itself. No host has been powered down by `upsmon` yet; this manual proves login, status and start-at-boot only.
> **Needs:** root on the host; a NUT server reachable on TCP 3493 with a UPS name and a user/password for this host; one reboot on Fedora Atomic if a deployment is already staged.
> **Time:** 15 min (plus one reboot on Fedora Atomic). **Risk:** once enabled, the host powers itself off when the server reports the UPS on battery and low battery, or when the server stays unreachable for `DEADTIME` while the UPS was last seen on battery. Reversible: Undo below restores the original configs.

## Before you start

- Placeholders, used in every step below:
  - `<UPS>`: the UPS name on the NUT server.
  - `<SERVER>`: the NUT server's address.
  - `<NUT_USER>`, `<NUT_PASSWORD>`: the login the server holds for this host, with `secondary` rights.
- Open a root shell (`sudo -i`) and keep it for all steps: the variables set in step 3 are used later.
- Check that the server answers and lists your UPS (`nc` is in the `nmap-ncat` package). Output from the Fedora 44 box, UPS names replaced, cut marked:

  ```console
  $ printf 'LIST UPS\nLOGOUT\n' | nc <SERVER> 3493
  BEGIN LIST UPS
  …
  UPS <UPS> "BLUETTI AC180, tier t2, via nut-observer"
  …
  END LIST UPS
  OK Goodbye
  ```

  Expected: your `<UPS>` appears between `BEGIN LIST UPS` and `END LIST UPS`.
- **Fedora Atomic only:** check free space with `df -h /sysroot` and look for a staged deployment with `rpm-ostree status`. Lines from both on the Bazzite box before the install, cut marked:

  ```text
  /dev/nvme0n1p3  930G  914G   15G  99% /sysroot
  …
  State: idle
  Deployments:
    ostree-image-signed:docker://ghcr.io/ublue-os/bazzite-nvidia-open:stable
  …
                    Version: 44.20261012.1 (2026-10-13T00:06:38Z)
  …
                       Diff: 406 upgraded, 13 removed, 54 added
  …
  ● ostree-image-signed:docker://ghcr.io/ublue-os/bazzite-nvidia-open:stable
  …
                    Version: 44.20260921 (2026-09-21T05:59:02Z)
  ```

  Expected: free space above rpm-ostree's 3 % floor (this box was at 99 % used, Failure modes row 1). A deployment listed above the booted one (`●`) is already staged; that is the state in which my live apply was refused (row 2).
- Backups: step 2 copies the existing `nut.conf` and `upsmon.conf` to `*.orig` once. Undo restores from them.

## Steps

1. **Install nut-client.**

   Fedora:

   ```bash
   dnf install -y nut-client
   ```

   Output on Fedora 45, cut marked:

   ```text
   …
   [1/1] Total                             100% |   1.1 MiB/s | 423.4 KiB |  00m00s
   Running transaction
   [1/3] Verify package files              100% | 500.0   B/s |   1.0   B |  00m00s
   [2/3] Prepare transaction               100% |   2.0   B/s |   1.0   B |  00m00s
   >>> Running sysusers scriptlet: nut-client-0:2.8.5-9.fc45.x86_64
   >>> Finished sysusers scriptlet: nut-client-0:2.8.5-9.fc45.x86_64
   >>> Scriptlet output:
   >>> Creating group 'nut' with GID 57.
   >>> Creating user 'nut' (Network UPS Tools) with UID 57 and GID 57.
   >>> 
   [3/3] Installing nut-client-0:2.8.5-9.f 100% | 546.2 KiB/s |   1.2 MiB |  00m02s
   Complete!
   ```

   Expected: `Complete!`. On a first install the `nut` user and group are created.

   Fedora Atomic:

   ```bash
   rpm-ostree install --idempotent --apply-live -y nut-client
   ```

   Output on Bazzite (with a deployment already staged, see Before you start), cut marked:

   ```text
   …
   Writing OSTree commit...done
   Staging deployment...done
   Freed: 140.4 MB (pkgcache branches: 0)
   error: packages would be removed: 13, allow replacement to override
   ```

   Expected: the package is applied live. I have not seen that path: on my box the live apply was refused (Failure modes row 2), the package went into the staged deployment, and a reboot brought it in.

   **If the live apply is refused like this (Failure modes row 2), run `systemctl reboot`; if the install failed on disk space (row 1), nothing was staged: free space and run the install again. Either way, check `rpm -q nut-client` before step 2.** Without the package on the booted deployment, steps 2–5 have nothing to configure. After the reboot:

   ```text
   2026-10-16 11:56:43
   nut-client-2.8.5-8.fc44.x86_64
   State: idle
   BootedDeployment:
   ● ostree-image-signed:docker://ghcr.io/ublue-os/bazzite-nvidia-open:stable
   …
                     Version: 44.20261012.1 (2026-10-13T00:06:38Z)
             LayeredPackages: coolercontrol lact liquidctl nut-client
   ```

   That is the output of `uptime -s; rpm -q nut-client; rpm-ostree status --booted | head -6` (the `head` cut drops the later lines, such as `LocalPackages`). Expected: `nut-client` in `LayeredPackages` and `rpm -q nut-client` prints a version.

2. **Back up the existing configs once.**

   ```bash
   for f in nut.conf upsmon.conf; do
     [ -f /etc/ups/$f ] && [ ! -f /etc/ups/$f.orig ] && cp -a /etc/ups/$f /etc/ups/$f.orig
   done
   ls /etc/ups/*.orig
   ```

   Output on Fedora 44:

   ```text
   /etc/ups/nut.conf.orig
   /etc/ups/upsmon.conf.orig
   ```

   Expected: both `.orig` files exist. Running the loop again does not overwrite them.

3. **Set the placeholders and read the password without putting it in argv or shell history.**

   ```bash
   UPS=<UPS> SERVER=<SERVER> NUT_USER=<NUT_USER>
   read -rs NUT_PASS
   ```

   Type `<NUT_PASSWORD>` and press Enter. No output.

4. **Set NUT to client mode.**

   ```bash
   printf 'MODE=netclient\n' > /etc/ups/nut.conf
   ```

   No output. `netclient` means this host runs only `upsmon` and no local driver or server.

5. **Write upsmon.conf as a secondary and make it readable by the nut group only.**

   The password goes into the `MONITOR` line unquoted, so use one without spaces or `#`. The `umask 027` stays in effect for the rest of this root shell.

   ```bash
   umask 027
   cat > /etc/ups/upsmon.conf <<EOF
   MONITOR $UPS@$SERVER 1 $NUT_USER $NUT_PASS secondary
   MINSUPPLIES 1
   SHUTDOWNCMD "/usr/sbin/shutdown -h +0"
   POLLFREQ 5
   POLLFREQALERT 5
   DEADTIME 300
   NOCOMMWARNTIME 300
   FINALDELAY 5
   EOF
   chown root:nut /etc/ups/upsmon.conf
   chmod 640 /etc/ups/upsmon.conf
   stat -c '%U:%G %a %n' /etc/ups/upsmon.conf
   ```

   The heredoc delimiter is unquoted on purpose, so the shell fills in the variables from step 3. Output on Fedora 44:

   ```text
   root:nut 640 /etc/ups/upsmon.conf
   ```

   Expected: `root:nut 640`. The file holds the password.

   What the lines do: `1` is the number of power supplies this UPS feeds on the host; `secondary` means this host shuts itself down and leaves powering off the UPS to the primary. `DEADTIME 300` lets the server go missing for 5 minutes before `upsmon` declares the UPS dead (the default is 15 s); why I picked 300 for my server is in [part 5 of the UPS series](https://tsvinc.dev/posts/nut-observer-fails-safe/). `FINALDELAY 5` is the wait between the shutdown notice and `SHUTDOWNCMD`.

6. **Check the login without arming a shutdown.** This runs `upsmon` in the foreground for 15 s from a temporary copy of the config whose `SHUTDOWNCMD` is `/bin/true`.

   ```bash
   T=$(mktemp -d /run/upsmon-verify.XXXXXX); chmod 750 "$T"; chgrp nut "$T"
   sed 's#^SHUTDOWNCMD .*#SHUTDOWNCMD "/bin/true"#' /etc/ups/upsmon.conf > "$T/upsmon.conf"
   cp /etc/ups/nut.conf "$T/"; chgrp nut "$T"/*; chmod 640 "$T"/*
   systemctl is-enabled nut-monitor.service nut.target 2>/dev/null | paste -sd' ' || true
   upsc "$UPS@$SERVER" ups.status
   NUT_CONFPATH=$T NUT_STATEPATH=$T timeout 15 upsmon -D -F 2>&1 | grep -E 'Logged into|Login|denied|ERR|failed to connect' || true
   rm -rf "$T"
   ```

   Output on Bazzite, names replaced, cut marked:

   ```text
   …
   disabled disabled
   OL
      0.013950	[D1:main-loop] Logged into UPS <UPS>@<SERVER>
   ```

   Expected: `upsc` prints the UPS status (`OL` = on line) and `upsmon` prints `Logged into UPS <UPS>@<SERVER>`. Fedora 44 Server printed the same three lines (timestamp `0.023528`). If the login line is missing, stop here: nothing is enabled yet.

7. **Enable nut.target and nut-monitor, then start it.** `nut-monitor.service` is `WantedBy=nut.target` only, so enabling the service alone does not start it at boot ([part 5](https://tsvinc.dev/posts/nut-observer-fails-safe/) has the story).

   ```bash
   systemctl enable nut.target nut-monitor.service
   systemctl restart nut-monitor.service
   ```

   Output on Fedora 45:

   ```text
   Created symlink '/etc/systemd/system/multi-user.target.wants/nut.target' → '/usr/lib/systemd/system/nut.target'.
   Created symlink '/etc/systemd/system/upsmon.service' → '/usr/lib/systemd/system/nut-monitor.service'.
   Created symlink '/etc/systemd/system/nut.target.wants/nut-monitor.service' → '/usr/lib/systemd/system/nut-monitor.service'.
   ```

   Expected: the `multi-user.target.wants/nut.target` symlink is created (on Fedora 44 only that first line appeared: `nut-monitor` was already enabled by an earlier run of my script).

8. **Read the service state, the UPS status and the last log lines.**

   ```bash
   sleep 5
   systemctl is-active nut-monitor.service; systemctl is-enabled nut.target
   upsc "$UPS@$SERVER" ups.status
   journalctl -u nut-monitor.service -n 6 --no-pager -o cat
   ```

   Output on Fedora 45, cut marked (Fedora 44 printed the same lines):

   ```text
   …
   active
   enabled
   OL
   Warning: no custom notification command defined, just so you know
   writepid: fopen /run/nut/upsmon.pid: Permission denied
   upsnotify: notify about state NOTIFY_STATE_READY_WITH_PID with libsystemd: was requested, but not running as a service unit now, will not spam more about it
   upsnotify: failed to notify about state NOTIFY_STATE_READY_WITH_PID: no notification tech defined, will not spam more about it
   upsmon: initialized OS integration for sleep inhibitor
   upsmon: initialized OS integration for sleep/wake monitoring
   ```

   Expected: `active`, `enabled`, and the UPS status. The `writepid` and `upsnotify` lines appeared on both enabled hosts; the service stayed active and the status read worked. I have not determined their cause (Failure modes row 3).

## Verify

Reboot the host, then check that `nut-monitor` came up with the boot and that both units are enabled. Output on Fedora 44, rebooted after step 8:

```console
$ who -b
         system boot  2026-10-16 12:17
$ systemctl show nut-monitor -p ActiveEnterTimestamp
ActiveEnterTimestamp=Fri 2026-10-16 12:17:08 EEST
$ systemctl is-enabled nut.target nut-monitor
enabled
enabled
```

Expected: `ActiveEnterTimestamp` falls in the same minute as the boot, and both units print `enabled`. This proves start-at-boot only. It does not prove the host shuts down on low battery: that drill has not been run.

## Undo / rollback

Stop and disable `upsmon`, then restore the original configs (from my rollback script):

```bash
systemctl disable --now nut-monitor.service || true
for f in nut.conf upsmon.conf; do
  [ -f /etc/ups/$f.orig ] && cp -a /etc/ups/$f.orig /etc/ups/$f
done
```

The package stays installed, `nut.target` stays enabled (`systemctl disable nut.target` turns it off too) and the `*.orig` files stay. To remove the package as well: `dnf remove nut-client`, or `rpm-ostree uninstall nut-client` on Fedora Atomic, which takes effect after a reboot. `[UNVERIFIED: package removal not run]`

`[UNVERIFIED: rollback not exercised on any host]`

## Failure modes

| Symptom (exact message) | Cause | Fix |
|---|---|---|
| 1. `error: importing RPMs: Importing package 'nut-client': Importing archive: ostree-tar: Failed to handle file: ostree-tar: Failed to import file: Writing content object: min-free-space-percent '3%' would be exceeded, at least 12.3 kB requested` | `/sysroot` was 99 % full (15G free of 930G). | Free space. With 190G free the same install got past the import. |
| 2. `error: packages would be removed: 13, allow replacement to override` (after `Staging deployment...done`) | A deployment (44.20261012.1) was already staged, and its diff listed `13 removed`. The counts match; I did not reproduce the error without the staged update. `[UNVERIFIED: cause]` | Reboot into the staged deployment. `nut-client` was layered in it after the reboot, with no second install run. |
| 3. `writepid: fopen /run/nut/upsmon.pid: Permission denied`, plus `upsnotify: … not running as a service unit now` | Not determined. Seen on both enabled hosts, after `systemctl restart` and again in the boot journal. | None applied. The service stays `active` and the status reads `OL`. Whether it affects a real shutdown is untested. |

## Field notes

- **2026-10-17, Fedora 44 KDE Plasma Desktop, nut-client 2.8.5-8.fc44:** checked after the 2026-10-16 reboot. Boot at 12:17, `nut-monitor` active at 12:17:08 EEST, `nut.target` and `nut-monitor` both enabled, `upsc` reads `OL`. The boot journal for the unit holds the same `writepid` and `upsnotify` lines as after the manual restart, plus a notice that no `POWERDOWNFLAG` is configured, which the same notice says matters little on a host that is not primary for any device. Same day, Fedora 45 Prerelease (nut-client 2.8.5-9.fc45): still enabled and active; that box has not rebooted since the install (last boot 2026-10-14), so start-at-boot is proven on Fedora 44 only.
- **2026-10-16, Bazzite 44.20261012.1 (rpm-ostree, booted 44.20260921 at the start):** the first install at 06:53 UTC stopped on the 3 % free-space floor: `/sysroot` was 99 % full. `rpm-ostree status` showed a deployment for 44.20261012.1 already staged above the booted 44.20260921, with `Diff: 406 upgraded, 13 removed, 54 added`. After I freed space (190G free), the install at 07:40 UTC imported the package, staged a new deployment, and then refused the live apply with `packages would be removed: 13`. I rebooted instead of overriding. The box came up at 11:56 (its local time) on 44.20261012.1 with `nut-client` in `LayeredPackages`. The login check (step 6) passed at 09:27 UTC; the service was left disabled on purpose.
- **2026-10-16, Fedora 44 Server (Fedora Linux 44 Server Edition), nut-client 2.8.5-8.fc44:** install, configs and the login check (step 6) at 06:52 UTC; `Logged into UPS` in the foreground run, service left disabled on purpose. The dnf install also printed `Symlinking /usr/sbin/upsmon -> /usr/bin/upsmon`.
- **2026-10-16, Fedora 44 KDE Plasma Desktop and Fedora 45 Prerelease:** enable runs at 06:50 and 06:51 UTC. On Fedora 44, `nut-client` had been installed by an earlier run on 2026-10-15 (22:11 UTC), which had also enabled `nut-monitor`, so this run's `systemctl enable` created only the `nut.target` symlink. Fedora 45 installed and enabled at 06:51 UTC. Both read `OL` and logged the same `writepid`/`upsnotify` warnings. All four hosts were set up with my install script, which has two modes: enable runs steps 2, 4, 5, 7 and 8 (no login check), verify runs steps 2, 4, 5 and 6 and stops before enabling. The desktops ran enable, the server and Bazzite ran verify, so no host ran step 6 directly followed by step 7.

## Notes

- **Why secondary:** my NUT server is read-only and each host watches one of its UPS names as a client. The design is in [the observer design post](https://tsvinc.dev/posts/ups-observer-speaks-nut/).
- **Fedora Atomic, enable path:** steps 7 and 8 ran on Fedora only. `/etc` is writable on Bazzite and the commands are the same, but I have not enabled the service there. `[UNVERIFIED: steps 7–8 on Fedora Atomic]`
- **Live apply on Atomic:** a live apply on a box with no staged deployment was not tried. `[UNVERIFIED]`
- The `writepid` warning is on my list to check before the shutdown drill, together with whether SELinux lets `SHUTDOWNCMD` run.

## Sources

- [upsmon.conf(5)](https://networkupstools.org/docs/man/upsmon.conf.html): `MONITOR` syntax (`<upsname>@<hostname>`, power value, `secondary`), `DEADTIME` (default 15 s), `FINALDELAY`, `NOCOMMWARNTIME` (default 300 s).
- [upsmon(8)](https://networkupstools.org/docs/man/upsmon.html): `-D`/`-F` foreground debug run, `NUT_CONFPATH`, dead-UPS handling while on battery.
- [nut.conf(5)](https://networkupstools.org/docs/man/nut.conf.html): `MODE=netclient`.
- [Architecture of apply-live](https://coreos.github.io/rpm-ostree/apply-live/): apply-live creates a pending deployment first and computes a package-level diff to tell additions from upgrades.
- [nut-observer README](https://gitlab.com/random-home-utils/nut-observer): the NUT server these hosts monitor.

