Skip to content

← All heartbeat examples · What Heartbeat is

Heartbeat for machines nothing can reach from outside

Every uptime monitor works from the outside in: it needs a stable address and an open port to probe. A home server behind carrier-grade NAT has neither. Nor does the Raspberry Pi in the garage, the NAS, the laptop running a week-long job, or the box in a client's rack on a network you do not control. A heartbeat is the other way round: the machine makes one outbound HTTPS request a minute, and anything that can open an outbound connection can be watched. No port is opened, no agent is installed, no account is created on the box. The only thing that lives there is the slot's URL.

The record is the point here. When a machine you cannot see goes down, the OFF / ON lines in the slot's note are the only account of what happened, written at the time by a third party into a note nobody can edit. Wi-Fi drops and ISP outages show up as gaps too, which is honest: the record describes the machine's reachability, not just its power.

1. Linux: Raspberry Pi, home server, NAS, client box

A systemd timer beats a crontab here because of OnBootSec: the first ping goes out 30 seconds after boot, so a reboot that finishes in under two minutes writes nothing and a longer one is an exact OFF / ON pair. The URL lives in a root-only file.

# /etc/heartbeat.env   (chmod 600)
HEARTBEAT_URL=https://freshjots.com/hb/<token>

# /etc/systemd/system/heartbeat.service
[Unit]
Description=Fresh Jots heartbeat ping
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/heartbeat.env
ExecStart=/usr/bin/curl -fsS --max-time 10 ${HEARTBEAT_URL}

# /etc/systemd/system/heartbeat.timer
[Unit]
Description=Ping the heartbeat slot every minute
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload && sudo systemctl enable --now heartbeat.timer

On a NAS or an appliance without a shell for systemd, the vendor's task scheduler running the same curl line every minute does the same job. Where only cron exists, one line: * * * * * curl -fsS --max-time 10 "$HEARTBEAT_URL" >/dev/null 2>&1, with the variable set at the top of the crontab.

2. macOS: a launchd agent

A per-user agent that launchd runs every 60 seconds and again on login. Save it as ~/Library/LaunchAgents/com.freshjots.heartbeat.plist; the token sits in the plist (replace YOUR_TOKEN; angle brackets would break the XML), so make the file readable by you alone (chmod 600). A sleeping laptop stops pinging and gets reported; that is correct for a machine that should be awake, and wrong for one that should not, so for "did my long job on this laptop finish" chain the ping after the job instead: ./job.sh && curl -fsS "$HEARTBEAT_URL".

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.freshjots.heartbeat</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/bin/curl</string>
    <string>-fsS</string>
    <string>--max-time</string><string>10</string>
    <string>https://freshjots.com/hb/YOUR_TOKEN</string>
  </array>
  <key>StartInterval</key><integer>60</integer>
  <key>RunAtLoad</key><true/>
</dict>
</plist>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.freshjots.heartbeat.plist

launchctl load <path> is the older form of the same command and still works. To stop the agent for good, remove it with launchctl bootout gui/$(id -u)/com.freshjots.heartbeat and switch the slot off.

3. Windows

Task Scheduler running a one-line PowerShell Invoke-WebRequest every minute, with the URL in a machine-level environment variable. The PowerShell lines that register it are on the main examples page.

4. Microcontrollers: an ESP32 in the greenhouse

A board with Wi-Fi can be watched the same way, and for most of them this is the only monitoring they will ever get. The ping is one HTTPS GET a minute from the main loop. The URL is the credential, so verify the server's certificate with its root CA rather than calling setInsecure(), which lets anyone on the path read the token; setInsecure() is fine on the bench, not in the field.

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

const char* HEARTBEAT_URL = "https://freshjots.com/hb/<token>";
const char* ROOT_CA = "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n";

void setup() {
  WiFi.begin("your-ssid", "your-password");
  while (WiFi.status() != WL_CONNECTED) delay(500);
}

void loop() {
  if (WiFi.status() == WL_CONNECTED) {
    WiFiClientSecure client;
    client.setCACert(ROOT_CA);
    HTTPClient http;
    if (http.begin(client, HEARTBEAT_URL)) {
      http.GET();            // 200 is all we need; a 404 means the slot is off or rotated
      http.end();
    }
  }
  delay(60 * 1000);
}

A board that deep-sleeps for longer than two minutes between wakes cannot hold a heartbeat; it belongs on the dead-man's switch, which watches an append-only log with a deadline measured in hours.

5. A client's rack: one slot per site

For every machine you look after on someone else's premises, one slot with the client's name as its label. The Heartbeats page becomes the list of sites and their state; the Settings → Uptime page gives each a 7- or 30-day score; and when a client says the box was down all week, the DAY / OFF / ON record, hashed and anchored like every other Fresh Jots note, is the answer, down to the minute. Point the slot's alert email at the client if they want to know first, or its webhook at your own on-call channel.

6. Gotchas

  • The clock on the box does not matter. Every line in the record is stamped by Fresh Jots in UTC when the check runs; a Pi with a wrong clock produces the same record.
  • One household, one IP. Every device behind the same router shares the 60-hits-a-minute IP limit. Once a minute per device leaves room for fifty of them.
  • A proxy in the way. On a corporate network that forces an HTTP proxy, set https_proxy in the same env file; curl honours it.
  • Keep the URL off the device's shared surfaces. Not in a README on the box, not in a wiki page, not in a group chat. If it does leak, Rotate URL on the slot page kills the old one at once and you update the one file on the device.
  • Link down and box down look the same from here, by design. If you need to tell them apart, a second slot pinged from another machine on the same link does it: both silent means the link, one silent means the box.