ESXi UPS Monitor – Automatic VM Shutdown on Battery Power

I Got Tired of Manually Shutting Down My Lab When the Power Goes Out

Living in a place with frequent power outages means my home lab gets an unexpected stress test a few times a year. The UPS kicks in, everything keeps running for a while, and if I’m home, I can gracefully shut things down. But if I’m out — groceries, work, travel — the UPS eventually drains, and the VMs crash uncleanly. Corrupted VMDK files, inconsistent databases, the kind of Tuesday I’d rather not have.

I have an APC UPS with an AP9631 network management card. That card speaks SNMP. And I thought: this thing already tells me exactly what’s going on — power status, battery charge, runtime remaining. So why not make it do the work for me?

The result is esxi-ups-monitor: a Go application that polls the UPS via SNMP, tracks battery state, shuts down ESXi VMs in stages as the battery drains, and starts them back up when power returns. It runs in a Docker container, sends Telegram notifications at every step, and has been handling my lab for a while now without any drama.

The Hardware

The AP9631 is APC’s network management card, commonly installed in Smart-UPS models. It exposes a full SNMP MIB with everything you’d want: output status (online vs. on battery), battery charge percentage, estimated runtime remaining, output load, and more.

I poll five OIDs in a single SNMP GET request:

OID What it gives you
.1.3.6.1.4.1.318.1.1.1.4.1.1.0 Output status: 2 = online, 3 = on battery
.1.3.6.1.4.1.318.1.1.1.2.2.1.0 Battery charge, 0–100%
.1.3.6.1.4.1.318.1.1.1.2.2.3.0 Runtime remaining (TimeTicks — divide by 100 for seconds)
.1.3.6.1.4.1.318.1.1.1.2.1.1.0 Battery status
.1.3.6.1.4.1.318.1.1.1.4.2.3.0 Output load percentage

The one-liner to test connectivity:

snmpwalk -v2c -c public 192.168.1.100 .1.3.6.1.4.1.318.1.1.1.2.1.1.0

If you get a number back, you’re good. If not, check your network config.

Why Go, Why SNMP, Why SSH

Why Go? Because I wanted a single binary that I could throw into a Docker image and forget about. Go is fine for this: the standard library covers SSH, and gosnmp handles the SNMP side. The output is a ~15MB static binary running inside Alpine. No interpreter, no runtime dependencies.

Why SNMP over the web interface? The AP9631 has a web UI you can scrape, but SNMP is more reliable and less intrusive. One UDP packet, one response, done. No parsing HTML, no session management, no dealing with login pages. SNMP is the right tool for this job.

Why SSH into ESXi directly instead of using vCenter? I don’t run vCenter. It’s a single ESXi host in my lab. Even if I did, the vCenter API is heavy overkill for shutting down VMs. SSH gives me direct access to esxcli vm process kill --type=soft and esxcli system shutdown poweroff. The app generates an SSH key on first run and logs the public key, so setup takes about 30 seconds.

How It Works

The core is a state machine with four states: ONLINE, ON_BATTERY, RECOVERING, and ESXI_SHUTDOWN. Every 10 seconds it polls the UPS, feeds the status into the state machine, and acts on whatever comes out.

ONLINE → ON_BATTERY

When the UPS reports output status 3 (on battery), we transition from ONLINE to ON_BATTERY. A timer starts. A Telegram message fires with current battery status.

ON_BATTERY: Stage Execution

You define stages in config.yaml, each with an after_seconds and/or runtime_remaining_seconds. Either condition triggers — whichever comes first. Example config:

stages:
  - after_seconds: 300
    runtime_remaining_seconds: 900
    vms:
      - plex
      - forgejo-runner
      - test-server

  - after_seconds: 600
    runtime_remaining_seconds: 600
    vms:
      - grafana
      - influxdb
      - homeassistant

  - after_seconds: 900
    runtime_remaining_seconds: 420
    vms:
      - docker-prod

The Final Stage

When battery is running low — the final stage kicks in. It shuts down the final VMs (including the monitor itself) and sends an ESXi shutdown command with a delay.

final:
  after_seconds: 1200
  runtime_remaining_seconds: 300
  delay_seconds: 90
  vms:
    - esxi-ups-monitor

The delay gives the container time to shut down cleanly before ESXi issues shutdown.

RECOVERING: Power Returns

When the UPS reports online again, we enter RECOVERING. Recovery waits until the battery has enough charge and runtime:

recovery:
  startup_delay_seconds: 120
  startup_runtime_remaining_seconds: 1800

ESXI_SHUTDOWN: The Sleep State

Once ESXi is powered off, the monitor sits in ESXI_SHUTDOWN, waiting for power to return.

Here’s the state machine visually:

UPS Power State MachineONLINEON_BATTERYRECOVERINGESXI_SHUTDOWNpower failurepower restoredfinal stagepower restored
State machine diagram

The Recovery Pattern

LIFO (last-in-first-out) startup. VMs shut down in order, and start in reverse. The last VM shut down is the first one started. This means core infrastructure comes back last, after the power is stable.

Here’s the architecture overview:

ESXi UPS Monitor ArchitectureAPC UPSAP9631 NMCSNMP v1/v2c/v3Go App in DockerSNMP MonitorState MachineESXi SSH ClientTelegram NotifierESXi 8 Hostplexgrafanadocker-prodTelegramstatus + stage notificationsSNMP GETSSH esxcli
Architecture diagram

Why Not Just Use NUT?

NUT (Network UPS Tools) is a solid project. But the staged shutdown with runtime-awareness was the main gap. NUT can run scripts on power events, but building per-stage thresholds with OR logic requires a custom script anyway. The Telegram integration, LIFO recovery, and graceful ESXi host shutdown with delay are all custom logic on top of raw UPS data. The monitor replaces the script I would have written on top of NUT, with SNMP polling built in.

Deployment

The setup takes about five minutes. Clone the repo, edit config.yaml, and run docker-compose up -d. On first run, the container generates an SSH key and prints the public key to the logs. Copy that key into ESXi authorized keys. Restart the container.

docker-compose up -d
docker-compose logs | grep "Public key"
# Paste into ESXi authorized keys
docker-compose restart

The docker-compose.yml mounts config read-only and creates ./secrets for the SSH key. Telegram env vars via .env. Cross-compiled binaries available for linux-amd64, arm64, armv7, and armv6 on the releases page.

Gotchas I Hit Along the Way

The container is one of the VMs it manages. The solution is the delay in the final stage: the monitor shuts itself down, then ESXi gets a delayed shutdown command. The delay gives the container time to finish — sending the last Telegram notification, closing connections.

SNMP TimeTicks are in hundredths of a second. The APC MIB returns runtime remaining as TimeTicks (1/100th sec). 30000 TimeTicks = 300 seconds = 5 minutes. The code divides by 100.

SSH host key validation. Using ssh.InsecureIgnoreHostKey() is reasonable for a home lab where the host key changes on ESXi reinstall.

ESXi finds VMs by world ID, not name. The ListVMs() function parses esxcli vm process list output to find world IDs matching VM names.

Here’s the sequence flow:

Power Failure Timelinet=0On BatteryUPS switches to batteryt=5minStage 1plex, forgejo-runner, test-servert=10minStage 2grafana, influxdb, homeassistantt=15minStage 3docker-prodt=20minFinalesxi-ups-monitor + ESXi host shutdownt=25minPower Restored
Sequence diagram

The Code

The project is written in Go, hosted on repo.noop.re/drops/esxi-apc. MIT licensed. Pull requests welcome.

If you have a similar setup — APC UPS with SNMP, ESXi host, a few VMs — clone it, tweak the config, and let me know if it works for you. Or if it breaks. Both are useful feedback.


Opublikowano

w

przez

Tagi: