Reliable Wake-on-LAN with a HomeKit Switch, Behind a UniFi Firewall
This is part of a small series on turning a headless Ubuntu NUC into a proper always-available server. The first post covers converting it from a Desktop install to headless and fixing a broken drive mount along the way. This one covers the actual goal: getting Wake-on-LAN to work reliably, and controlling it remotely as a HomeKit switch.

Goal
- Wake reliably over the network (Wake-on-LAN).
- Control power state remotely as a HomeKit switch, so I can suspend or wake it from my phone without SSHing in.
Enable Wake-on-LAN
First, find your network interface:
ip link
Check current WOL support and status:
sudo ethtool <interface> | grep Wake-on
Enable magic-packet wake for the current session:
sudo ethtool -s <interface> wol g
This setting resets on reboot unless you persist it. If NetworkManager manages your interface (common even on headless Ubuntu, if it started life as a Desktop install), persist the setting there instead. First find the connection profile name:
nmcli connection show
Find the entry whose DEVICE column matches your interface (e.g. eno1), and use its NAME value wherever you see <connection-name> below. Ubuntu autogenerates this name on many installs, often as something like netplan-eno1.
sudo nmcli connection modify "<connection-name>" 802-3-ethernet.wake-on-lan magic sudo nmcli connection up "<connection-name>"

A few more settings to unblock WOL
Energy-Efficient Ethernet (EEE)
Many NICs negotiate a low-power link state that can prevent the interface from ever seeing an incoming magic packet in the first place. Check and disable it:
sudo ethtool --show-eee <interface> sudo ethtool --set-eee <interface> eee off
USB armed as an ACPI wakeup source
The machine would suspend, then wake itself again within seconds, for no obvious reason. Checking /proc/acpi/wakeup showed the USB controller (XHC) set as an active wakeup source. That meant any USB peripheral activity, like a mouse jiggle or wireless dongle chatter, could trigger a resume.
cat /proc/acpi/wakeup | grep XHC
Disable it (echoing the device name toggles its current state):
echo XHC | sudo tee /proc/acpi/wakeup

Making both fixes persistent
Both of these settings reset on every reboot, so make them persistent with a systemd oneshot service. I wrote the ExecStart line as a single line rather than using backslash line-continuations, since some editors and CMSes silently strip those. Create the unit file:
sudo nano /etc/systemd/system/nuc-power-fixes.service
Paste this in:
# /etc/systemd/system/nuc-power-fixes.service [Unit] Description=Disable EEE and fix ACPI wakeup sources for reliable WOL After=network.target [Service] Type=oneshot ExecStart=/bin/sh -c 'ethtool --set-eee <interface> eee off; grep -q "XHC.*enabled" /proc/acpi/wakeup && echo XHC > /proc/acpi/wakeup; exit 0' [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable --now nuc-power-fixes.service
EEE resets itself on every resume
A fourth issue only showed up after using this setup for a while: EEE resets itself on every resume. WOL worked fine right after setup, then started failing intermittently over the following days. Checking again after a suspend/resume cycle showed EEE back to enabled, even though the systemd service above had already run and disabled it. That service only runs once, at boot. The NIC driver reinitializes on resume from suspend, quietly re-enabling EEE each time. The other settings held fine across resumes, just not EEE.
The fix is a systemd sleep hook. It runs automatically both before suspend and after resume, rather than only at boot:
sudo nano /usr/lib/systemd/system-sleep/nuc-wol-fix.sh
#!/bin/bash
case "$1" in
post)
ethtool -s <interface> wol g
ethtool --set-eee <interface> eee off
grep -q "XHC.*enabled" /proc/acpi/wakeup && echo XHC > /proc/acpi/wakeup
;;
esac
exit 0
sudo chmod +x /usr/lib/systemd/system-sleep/nuc-wol-fix.sh
The post case runs after the system wakes, exactly when the driver would have reset EEE. Confirm it’s working: suspend the machine, wake it back up, and check immediately after:
sudo ethtool <interface> | grep Wake-on sudo ethtool --show-eee <interface> | grep status
EEE status should now correctly show disabled right after resume, instead of drifting back to enabled. If your WOL setup works at first and then becomes unreliable after a day or two, check for this resume-drift behaviour before assuming it’s a network or router issue.
A HomeKit switch to control it remotely
For remote control, I used Homebridge with a plugin that maps a HomeKit switch to shell commands. I first tried homebridge-cmdaccessory, but its maintainer hasn’t updated it for Homebridge v2’s breaking API changes, and it crashed on startup after Homebridge removed updateReachability(). I switched to the actively maintained homebridge-cmd4 instead.

The Cmd4 control script
Cmd4 calls a single script with Get/Set arguments, rather than using three separate command strings. This script also avoids line-continuation backslashes in the SSH call, for the same reason as above. Create it on the machine running Homebridge, not the server itself. It calls etherwake to wake the server, so that needs to be installed on the Homebridge machine too. It’s not installed by default, so grab it first if you haven’t needed it before:
sudo apt install etherwake
Then create the script:
sudo nano /var/lib/homebridge/cmd4-server.sh
Paste this in:
#!/bin/bash
# Cmd4 control script for a power switch
# Called by Homebridge as:
# Get <name> On
# Set <name> On 1|0
#
# $1 = ACTION (Get | Set)
# $2 = NAME
# $3 = CHARACTERISTIC
# $4 = VALUE (Cmd4 passes 1/0, not the strings "true"/"false")
ACTION="$1"
VALUE="$4"
MAC="<your-mac-address>"
IP="<your-server-ip>"
SSH_KEY="/home/homebridge/.ssh/<key-name>"
SSH_USER="<your-server-username>"
if [ "$ACTION" == "Get" ]; then
if ping -c 1 -W 1 "$IP" > /dev/null 2>&1; then
echo "TRUE"
else
echo "FALSE"
fi
elif [ "$ACTION" == "Set" ]; then
if [ "$VALUE" == "1" ]; then
etherwake -i wlan0 "$MAC"
else
ssh -i "$SSH_KEY" -o ConnectTimeout=5 -o StrictHostKeyChecking=no "$SSH_USER@$IP" 'sudo systemctl suspend'
fi
fi
sudo chmod +x /var/lib/homebridge/cmd4-server.sh
Two things worth calling out explicitly:
- Cmd4 passes
1/0for boolean values, not the strings"true"/"false". My first version checked for"true", which never matched. That meant toggling the switch “on” silently ran the suspend branch instead ofetherwake, since the condition fell through toelse. Turning the switch off worked fine by coincidence, since that’s the branch that kept firing. Turning it on threw an SSH timeout instead, since it was trying to SSH into a sleeping machine. Check what your plugin actually sends on the wire (enable debug logging) rather than assuming a convention. - Homebridge runs as its own dedicated service user, not whatever user you’re logged in as when you set things up. Any SSH key a script references needs to live under that service user’s home directory (e.g.
/home/homebridge/.ssh/). Generate and permission the key as that user, not your own login.

sudo -u homebridge ssh-keygen -t ed25519 -N "" -f /home/homebridge/.ssh/<key-name> sudo -u homebridge ssh-copy-id -i /home/homebridge/.ssh/<key-name>.pub <user>@<server-ip> sudo chown -R homebridge:homebridge /home/homebridge/.ssh sudo chmod 700 /home/homebridge/.ssh sudo chmod 600 /home/homebridge/.ssh/<key-name>
Always reference the key by absolute path in the script. ~ expands to the wrong directory when the Homebridge process invokes the script, rather than an interactive shell.
Test the script directly as the service user before trusting HomeKit to exercise it:
sudo -u homebridge /path/to/cmd4-script.sh Set <name> On 1 sudo -u homebridge /path/to/cmd4-script.sh Set <name> On 0
Adding the switch to Homebridge’s config
Add this as a new entry in the platforms array of Homebridge’s main config file, usually /var/lib/homebridge/config.json. If you’re not comfortable hand-editing JSON that other plugins also share, use the Homebridge UI’s Config editor instead:
{
"platform": "Cmd4",
"name": "Cmd4",
"accessories": [
{
"type": "Switch",
"displayName": "Server",
"on": "FALSE",
"name": "Server",
"state_cmd": "/path/to/cmd4-server.sh",
"polling": [
{ "characteristic": "on", "interval": 600, "timeout": 8000 }
]
}
],
"_bridge": {
"username": "<generated-mac-style-id>",
"port": 30839,
"name": "Homebridge Cmd4"
}
}

The polling interval (in seconds) controls how often Cmd4 calls Get to refresh the switch’s state in the Home app on its own. This is separate from any manual toggle. Cmd4 also calls Get a few seconds after every Set, to confirm the change actually took effect rather than assuming it worked.
Passwordless suspend via sudoers
For the remote suspend command to work without hanging on a password prompt, set up a narrow sudoers rule. Grant passwordless systemctl suspend specifically, rather than broad sudo access. Run this on the server, not the Homebridge machine:
sudo visudo -f /etc/sudoers.d/allow-suspend
Add this single line:
<user> ALL=(ALL) NOPASSWD: /usr/bin/systemctl suspend
visudo validates the syntax on save. It won’t let you write a broken sudoers file that could lock you out of sudo entirely.
Adding the bridge to the Home app
Once the child bridge is running (check the Homebridge UI shows it as “Running”, not stopped/errored):
- In the Home app: + then Add Accessory then More options…
- Select the new bridge from the list of nearby accessories.
- Enter the setup PIN, found either in the Homebridge UI’s child bridge settings or in the startup logs.


If your Homebridge instance is on a different VLAN/subnet than your phone, HomeKit discovery, which relies on local network mDNS/Bonjour, may not find it. Same underlying issue as the firewall/VLAN topic below.
UniFi firewall rules for a Homebridge host on a separate VLAN
If your Homebridge instance lives on a separate VLAN from your server (in my case, an isolated DMZ network for a Raspberry Pi, which is good security practice), you’ll need narrow firewall rules. UniFi blocks inter-VLAN traffic by default, so only the specific traffic below needs an explicit allow rule between the two zones:
- SSH: protocol TCP, destination port 22.
- Wake-on-LAN: protocol UDP, destination port 9.
- Ping/health-check: protocol ICMP. Note that ICMP doesn’t use a port at all. In theory, UniFi’s newer rule builder lets you select ICMP as a protocol type, sometimes under a “Custom” option specifying IP protocol number
1.
Creating the rules in UniFi
In the UniFi rule builder, for each rule: set Source Zone to your isolated VLAN (e.g. DMZ), and switch from “Any” to the specific source IP. Set Action to Allow. Set Destination Zone to your main network, and again switch from “Any” to the specific server IP. Then set the Protocol and Port fields as above. Scope each rule as narrowly as possible: specific source IP to specific destination IP, rather than “Any” to “Any” within the zones. That way the isolated network stays isolated from everything else.

If the traffic still isn’t passing through, try setting the rule’s Protocol to All instead. That’s what got it working for me, so it’s worth trying first if the ICMP-specific option doesn’t seem to take effect.
WOL across subnets and from a WiFi controller
Also remember that Wake-on-LAN’s broadcast packet doesn’t cross subnets on its own. You need to send a directed packet to the target’s actual IP:
wakeonlan -i <target-ip> <mac-address>
I found etherwake to be more reliable than wakeonlan for sending a directed packet across subnets, especially from a controller on WiFi. Same idea, different tool:
etherwake -i wlan0 <mac-address>
(Same install as noted earlier: sudo apt install etherwake.)
The device sending the WOL packet doesn’t need any special wireless capability. If your controller (e.g. a Raspberry Pi running Homebridge) is on WiFi, that’s fine, since it’s just sending a normal UDP packet like any other network traffic. WiFi-related wake limitations (WoWLAN) only matter for the device that itself needs to wake up while asleep. In my case the NUC is wired, which is the reliable configuration for the receiving end of a magic packet.
Result
WOL now works reliably, both immediately after setup and days later, and I can wake or suspend the server from a HomeKit switch on my phone without SSHing in. The other post in this series covers making sure the machine doesn’t suspend itself out from under an active file transfer or SSH session in the first place.
If this post has been useful, support me by buying me a latte or two 🙂
