cd /news/developer-tools/building-unifi-reactor-state-driven-… · home topics developer-tools article
[ARTICLE · art-98643] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Building UniFi Reactor: state-driven network automation for my Kubernetes homelab

Robbe Verhelst built UniFi Reactor, a Kubernetes operator that watches UniFi Network state and turns it into declarative cluster automations. The operator polls the UniFi Network API, normalizes the state into keys, and reconciles Kubernetes Automation resources, enabling actions like scaling down ML workloads when the UPS is on battery or pausing downloads on backup WAN.

read6 min views4 publishedAug 16, 2026

A few weeks ago I added a UPS and backup internet to my homelab. That solved one class of problem, but it made another one more obvious.

My UniFi gear knew when the WAN failed over. It knew when the UPS switched to battery. Kubernetes did not.

So if the main WAN failed at 3 AM, qBittorrent could keep happily seeding over a metered backup link. If power dropped, the UPS could be counting down its remaining runtime while the cluster continued doing background ML jobs, backups, and other work that absolutely did not need to happen during an outage.

That felt wrong. The network already had the state. The cluster just needed to react to it.

That is why I built UniFi Reactor: a Kubernetes operator that watches UniFi Network state and turns it into declarative cluster automations.

Docs are here: reactor.robbeverhelst.com

UniFi Reactor polls the UniFi Network API, normalizes what it sees into state keys, and reconciles Kubernetes Automation

resources against those keys.

Examples of state keys:

wan: primary

or wan: backup

internet: ok

, degraded

, or down

ups: online

or on-battery

ups.battery: normal

, low

, or critical

devices: all-online

or degraded

device.<name>: online

or offline

Then you write rules like this:

apiVersion: reactor.robbeverhelst.com/v1alpha1
kind: Automation
metadata:
  name: shed-ml-on-battery
  namespace: reactor-system
spec:
  when:
    provider: unifi
    state:
      ups: on-battery
  actions:
    - type: kubernetes.scale
      target:
        kind: Deployment
        name: immich-machine-learning
        namespace: immich
      replicas: 0
  onExit:
    - type: kubernetes.scale
      target:
        kind: Deployment
        name: immich-machine-learning
        namespace: immich
      replicas: 1

When the UPS goes on battery, Reactor scales down Immich machine learning. When mains power returns, it scales it back up.

That was the first real automation I deployed because it is boring in exactly the right way. Photo indexing is useful, but nobody cares if it s during a power cut.

I already have Prometheus, Grafana, Gatus, and the usual homelab observability stack. They are good at telling me something happened.

But an alert that says "the UPS is on battery" is only half useful. The question I actually care about is: what should the system do while that condition is true?

That difference shaped the project:

For simple notification-only cases, Prometheus is probably still the better tool. Reactor becomes interesting when the response is operational: downloads, suspend CronJobs, scale down optional workloads, disable guest WiFi, or shed PoE load.

The first design choice was to make Reactor state-driven instead of event-driven.

That means polling is the source of truth. Webhooks can be a fast path later, but they should not be the mechanism of record.

Why? Because one-shot events are easy to miss. The controller can restart. The network can flap. A webhook can fail. If the system only reacts to edges, it can get stranded in the wrong mode.

With state reconciliation, the next observation corrects things.

The mental model is closer to Kubernetes itself: observe reality, compare it to desired state, reconcile.

A simplified flow looks like this:

UniFi Network API
      |
      v
UniFi provider observes hardware state
      |
      v
Normalized keys: wan, ups, internet, devices, ...
      |
      v
Reactor matches Automation resources
      |
      v
Actions are applied while conditions hold

The flagship use case is metered backup internet.

I run a UniFi setup with a primary WAN and backup connectivity. Once failover is verified end-to-end, I want this kind of automation:

apiVersion: reactor.robbeverhelst.com/v1alpha1
kind: Automation
metadata:
  name: -downloads-on-backup-wan
  namespace: media
spec:
  when:
    provider: unifi
    state:
      wan: backup
  actions:
    - type: kubernetes.scale
      target:
        kind: Deployment
        name: qbittorrent
        namespace: servarr
      replicas: 0
  onExit:
    - type: kubernetes.scale
      target:
        kind: Deployment
        name: qbittorrent
        namespace: servarr
      replicas: 1

When the network is on backup WAN, downloads stop. When primary WAN returns, they resume.

That is a tiny YAML file, but it encodes a very practical policy: do not burn backup data on background traffic.

Other examples I want to add around this:

Some of those are not implemented yet, but the model is clear.

Power loss is the other obvious axis.

A UPS buys time. The cluster should spend that time intelligently.

The first deployed reaction was:

ups: on-battery

-> scale immich-machine-learning

from 1 to 0Future power-loss automations can become more aggressive as the battery drains:

ups: on-battery

-> suspend non-critical CronJobsups.battery: low

-> scale down heavier optional workloadsups.battery: critical

-> shut down or isolate more aggressivelyOne important detail: ups

and ups.battery

are separate keys.

That is deliberate. If a single enum went from online

to on-battery

to battery-low

to critical

, an automation matching on-battery

would stop matching when the battery became low. That could accidentally fire onExit

and scale workloads back up during the outage.

Separate keys avoid that. An automation can match ups: on-battery

for the whole outage, while another automation can additionally match ups.battery: critical

for escalation.

A surprisingly important part of the design is onExit

.

Reactor does not guess how to undo something. The automation says what should happen when the condition starts holding, and what should happen when it stops.

actions:
  - type: kubernetes.scale
    target:
      kind: Deployment
      name: qbittorrent
      namespace: servarr
    replicas: 0

onExit:
  - type: kubernetes.scale
    target:
      kind: Deployment
      name: qbittorrent
      namespace: servarr
    replicas: 1

That is more verbose than magic, but it is safer. Infrastructure automation gets scary when the tool assumes what "back to normal" means.

This was the part that made the project more than a wrapper around a few scripts.

Imagine qBittorrent should when:

Those are independent reasons. Either one should keep qBittorrent down.

If one automation exits while the other still matches, Reactor must not scale the deployment back up.

So Reactor arbitrates shared targets. Desired-state actions like kubernetes.scale

are treated as levels. The most restrictive active level wins. A workload comes back only when no matching automation still wants it down.

That avoids the classic failure mode where two scripts fight each other:

Reactor tracks the active claims instead.

I wanted this to be safe enough to run in my actual cluster, not just impressive in a README.

The operator uses:

cluster-admin

For example, actions like HTTP requests, UniFi WLAN changes, PoE cycling, and UPS outlet control are refused unless the destination or target is explicitly allowed.

That matters because a Kubernetes operator with access to your network controller is a sharp tool. It should not become "curl with cluster permissions".

The project already supports more than just scaling Deployments.

Action families include:

kubernetes.scale

kubernetes.cronjob.suspend

kubernetes.cordon

kubernetes.restart

http.request

notification.ntfy

, notification.discord

, notification.slack

homeassistant.service

qbittorrent.

and qbittorrent.resume

unifi.wlan.enable

and unifi.wlan.disable

unifi.poe.cycle

unifi.outlet.cut

and unifi.outlet.restore

There is intentionally no arbitrary shell action. That would be convenient, and also how you turn a small operator into a haunted CI runner.

A few lessons stood out.

First, UniFi exposes useful operational state, but it is not always shaped the way you would design a clean public API. Normalizing it into a small vocabulary is worth the effort.

Second, "event-driven" sounds elegant until you care about recovery. For infrastructure state, reconciliation beats clever edge handling.

Third, undo behavior deserves first-class design. It is easy to write automation that does something. It is harder to make it stop doing that thing at the right time.

Fourth, homelab projects are best when they are boring under pressure. A power cut is not the moment to discover your automation has opinions.

The project is public and released as a multi-arch container image and Helm chart.

v1.2.0

The next things I want to harden are:

The broader idea is simple: your infrastructure already knows a lot about the conditions it is running under. UniFi Reactor is my attempt to let Kubernetes respond to that context without turning the homelab into a pile of one-off scripts.

That is the kind of boring automation I want more of.

── more in #developer-tools 4 stories · sorted by recency
── more on @unifi reactor 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-unifi-react…] indexed:0 read:6min 2026-08-16 ·