tutorial

How to Monitor Nerves with Vigilmon

Add uptime monitoring, heartbeat checks, and alerts to your Nerves-powered Elixir embedded systems and IoT firmware using Vigilmon.

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):

  1. Click New Monitor → Heartbeat
  2. Set expected interval: 5 minutes (match your @interval)
  3. Set grace period: 10 minutes (allows for brief connectivity loss)
  4. 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):

  1. Sign up at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter http://device.local:4000/health or the device's fixed IP
  4. Set check interval: 1 minute
  5. Add a body keyword: "status":"ok"
  6. 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:

  1. In Vigilmon, go to Status Pages → New Status Page
  2. Add monitors for each device or device group
  3. Label them by location or function: "Warehouse A — Sensor Array", "Conveyor Belt Controller"
  4. 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.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →