How to Monitor Nerves with Vigilmon
Nerves brings the power of Elixir to embedded Linux systems and IoT hardware. Your firmware runs on a minimal, read-only Linux root filesystem, BEAM handles process supervision, and your Elixir application manages everything from network interfaces to GPIO pins.
But embedded systems have unique monitoring challenges. Devices deploy to remote locations, network connectivity is intermittent, and a device that stops checking in might be offline, crashed, or physically damaged — and you often can't tell which without external visibility.
This tutorial adds production observability to a Nerves deployment:
- An HTTP health check endpoint on the device
- Heartbeat monitoring so you know when devices go silent
- Vigilmon uptime checks and alerts
- Fleet-level status tracking across multiple devices
- Slack alerts for device dropouts
Why Monitor Nerves Devices?
Nerves handles a lot automatically: Erlang's supervisor trees restart crashed processes, nerves_runtime watchdogs reboot unresponsive devices, and nerves_hub manages OTA firmware updates. But these don't tell you:
- A device has dropped off the network — disconnected from WiFi, ISP outage, or hardware failure
- A device rebooted unexpectedly — watchdog triggered, power cycle, or a kernel panic
- An application process silently stopped working — data collection halted, sensor reads returning errors
- Firmware update failed — device running old firmware after a failed OTA push
- The device is reachable but degraded — memory exhausted, filesystem full, CPU pegged
External monitoring catches what process supervision cannot: device absence.
Step 1: Add an HTTP health endpoint to your Nerves firmware
Nerves firmware can run a minimal HTTP server using bandit (or cowboy). Add it to your device firmware:
# mix.exs
defp deps do
[
{:nerves, "~> 1.10", runtime: false},
{:shoehorn, "~> 0.9"},
{:ring_logger, "~> 0.11"},
{:toolshed, "~> 0.4"},
{:bandit, "~> 1.0"}, # ← add this
{:jason, "~> 1.4"}
]
end
Create a minimal Plug router for health checks:
# lib/my_firmware/health_router.ex
defmodule MyFirmware.HealthRouter do
use Plug.Router
plug :match
plug :dispatch
get "/health" do
checks = run_checks()
status = if all_ok?(checks), do: 200, else: 503
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{
status: status_label(status),
checks: checks,
firmware: firmware_info()
}))
end
match _ do
send_resp(conn, 404, "not found")
end
defp run_checks do
%{
memory: check_memory(),
filesystem: check_filesystem(),
network: check_network()
}
end
defp check_memory do
case :memsup.get_system_memory_data() do
[] -> "ok"
data ->
total = Keyword.get(data, :total_memory, 1)
free = Keyword.get(data, :free_memory, total)
used_pct = (total - free) / total * 100
if used_pct < 90, do: "ok", else: "high"
end
end
defp check_filesystem do
case System.cmd("df", ["-h", "/root"]) do
{output, 0} ->
# Parse usage percentage from df output
case Regex.run(~r/(\d+)%/, output) do
[_, pct_str] ->
pct = String.to_integer(pct_str)
if pct < 90, do: "ok", else: "full"
_ -> "ok"
end
_ -> "error"
end
end
defp check_network do
case :inet.getifaddrs() do
{:ok, addrs} ->
has_ip = Enum.any?(addrs, fn {_iface, opts} ->
Keyword.get(opts, :addr) != nil and
Keyword.get(opts, :addr) != {127, 0, 0, 1}
end)
if has_ip, do: "ok", else: "no_ip"
_ -> "error"
end
end
defp firmware_info do
%{
version: Application.spec(:my_firmware, :vsn) |> to_string(),
otp_version: System.otp_release()
}
end
defp all_ok?(checks) do
Enum.all?(checks, fn {_, v} -> v == "ok" end)
end
defp status_label(200), do: "ok"
defp status_label(_), do: "degraded"
end
Start the server in your application supervisor:
# lib/my_firmware/application.ex
defmodule MyFirmware.Application do
use Application
def start(_type, _args) do
children = [
{Bandit, plug: MyFirmware.HealthRouter, port: 4000},
# ... your other children
]
Supervisor.start_link(children, strategy: :one_for_one)
end
end
Test on a device:
curl http://192.168.1.100:4000/health
# {"status":"ok","checks":{"memory":"ok","filesystem":"ok","network":"ok"},
# "firmware":{"version":"0.1.0","otp_version":"26"}}
Step 2: Heartbeat monitoring — the core pattern for IoT
For embedded devices, heartbeat monitoring is more important than HTTP uptime probes. A device behind a NAT or on a cellular connection may not be reachable inbound. Instead, the device proactively pings Vigilmon at the end of each successful work cycle.
# lib/my_firmware/heartbeat.ex
defmodule MyFirmware.Heartbeat do
use GenServer
require Logger
@interval :timer.minutes(5)
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@impl true
def init(state) do
schedule_ping()
{:ok, state}
end
@impl true
def handle_info(:ping, state) do
case ping_vigilmon() do
:ok ->
Logger.debug("Vigilmon heartbeat sent")
{:error, reason} ->
Logger.warning("Vigilmon heartbeat failed: #{inspect(reason)}")
end
schedule_ping()
{:noreply, state}
end
defp ping_vigilmon do
url = Application.get_env(:my_firmware, :vigilmon_heartbeat_url)
unless is_nil(url) do
case Req.get(url, receive_timeout: 10_000) do
{:ok, %{status: 200}} -> :ok
{:ok, resp} -> {:error, {:unexpected_status, resp.status}}
{:error, reason} -> {:error, reason}
end
else
:ok
end
end
defp schedule_ping do
Process.send_after(self(), :ping, @interval)
end
end
Configure the heartbeat URL:
# config/target.exs (device-only config)
config :my_firmware,
vigilmon_heartbeat_url: System.get_env("VIGILMON_DEVICE_HEARTBEAT_URL")
For a fleet of devices, each device needs its own heartbeat URL. Generate them programmatically using Vigilmon's API, keyed by device serial number.
In Vigilmon, create a Heartbeat Monitor per device (or per device group for large fleets):
- Click New Monitor → Heartbeat
- Set expected interval: 5 minutes (match your
@interval) - Set grace period: 10 minutes (allows for brief connectivity loss)
- Copy the ping URL and deploy it as an env var to the device
Step 3: Set up HTTP monitoring in Vigilmon (for LAN-accessible devices)
If your Nerves devices are accessible from a known IP or hostname (LAN deployment, Tailscale/WireGuard VPN, or cloud-bridged):
- Sign up at vigilmon.online
- Click New Monitor → HTTP
- Enter
http://device.local:4000/healthor the device's fixed IP - Set check interval: 1 minute
- Add a body keyword:
"status":"ok" - Save
For mDNS-based hostnames (device.local), this works on the same LAN. For remote devices, use the heartbeat approach from Step 2.
Step 4: OTA update monitoring
NervesHub handles firmware updates, but you should monitor whether devices are actually running the expected firmware version after a push.
Add a firmware version check to your health endpoint response (shown in Step 1 via firmware_info/0). Then create a Vigilmon Keyword Monitor that checks for the expected version string:
Expected keyword in response body: "version":"1.2.0"
If a device is stuck on an old version after a rollout, the keyword check fails and you get an alert.
Step 5: Slack alerts
In Vigilmon, go to Notifications → New Channel → Slack and paste your Slack incoming webhook URL.
Enable the channel on your heartbeat and HTTP monitors. When a device goes silent:
🔴 MISSED: Factory Floor Sensor Heartbeat
Device: device-003 (serial: SN-7291-B)
Last ping: 18 minutes ago (expected: every 5 minutes)
When it recovers:
✅ RECOVERED: Factory Floor Sensor Heartbeat
Downtime: 23 minutes
Step 6: Fleet status page
For multiple Nerves devices:
- In Vigilmon, go to Status Pages → New Status Page
- Add monitors for each device or device group
- Label them by location or function: "Warehouse A — Sensor Array", "Conveyor Belt Controller"
- Share the URL with operations teams
When a field technician asks "is Device 7 online?", they check the status page.
What you've built
| What | How |
|------|-----|
| Device health endpoint | Bandit + Plug router on port 4000 |
| Memory/filesystem/network checks | :memsup, df, :inet |
| Heartbeat monitoring | GenServer pinging Vigilmon every 5 minutes |
| HTTP monitoring | Vigilmon HTTP monitor (LAN/VPN devices) |
| OTA version tracking | Keyword check on firmware version string |
| Slack device alerts | Vigilmon Slack notification channel |
| Fleet status page | Vigilmon public status page per device |
BEAM keeps your firmware alive. Vigilmon tells you when the device itself disappears.
Next steps
- Generate per-device Vigilmon heartbeat URLs at provisioning time using the Vigilmon API
- Add GPIO or sensor-specific checks to your health endpoint (e.g. "last sensor read was < 60 seconds ago")
- Use Vigilmon's response time history to track whether device responsiveness degrades over uptime
- Set up group monitors for device fleets with shared SLAs
Get started free at vigilmon.online.