Alert on a stopped RouterOS container via ntfy and Telegram
Contents
This installs a RouterOS 7 scheduler script that checks named containers once a minute and sends one message to ntfy and Telegram each time the set of stopped containers changes. The endpoints and tokens are read from a container envlist at run time, so the script file holds no secrets.
- Tested on
- MikroTik hAP ax^3, RouterOS 7.24.4 (stable), watching containers
observerandmosquitto(2026-10-16 install and test message; 2026-10-17 read-only check). The down path (a container actually stopping) has not been triggered yet. - Needs
- RouterOS 7 with the container package and at least one container; SSH and SCP to the router (I used
admin, groupfull; a narrower user needs at leastsshandftpon top ofread,write,test, untested); a disk on the router for the.rscfile; an ntfy topic URL and/or a Telegram bot token plus chat id. - Time
- 15 min.
- Risk
- low. The script only reads container state and sends HTTP requests. Adding the sink settings to an envlist that a running container uses puts them into that container's environment on its next re-create. Rollback removes two scripts, one scheduler entry and the envlist keys.
Before you start#
Placeholders used below:
| Placeholder | Meaning |
|---|---|
<ROUTER> | user@address of the router; add your own SSH options (key, -o ControlPath=none) |
<NTFY_URL> | full ntfy topic URL, https://ntfy.sh/<long-random-topic> |
<NTFY_TOKEN> | optional ntfy access token (tk_…); leave the key out if the topic needs none |
<TG_TOKEN> | Telegram bot token from BotFather |
<CHAT_ID> | Telegram chat id the bot writes to |
- RouterOS version. Same command and router as the field notes, output cut to the relevant lines:
$ ssh <ROUTER> '/system/resource/print' … version: 7.24.4 (stable) … architecture-name: arm64 board-name: hAP ax^3 … - The containers to watch exist and run. The script below watches
observerandmosquitto; change line 42 of the.rscfor your names.$ ssh <ROUTER> '/container/print proplist=name,running where name~"^(observer|mosquitto)\$"' Flags: R - RUNNING Columns: NAME, RUNNING # NAME RUNNING ;;; mosquitto 2 R mosquitto yes ;;; observer 1 R observer yes - Envlist name. The script reads envlist
observer(line 16 of the.rsc), which my observer container already uses for the same ntfy and Telegram settings. Use any list name; if no container uses it, the keys only serve the script. - Secret values must not contain a double quote,
$, backslash,;or space. They are pasted into a RouterOS command inside double quotes, and those characters break the quoting. My installer refuses such values; check yours by hand. - Backup. Export the config before step 1 and copy the file off the router (my disk is
usb1-part1; substitute yours here and in steps 3–5):The export is a secret file. On 7.24.4 a plainssh <ROUTER> '/export file=usb1-part1/backups/pre-watchdog' scp <ROUTER>:/usb1-part1/backups/pre-watchdog.rsc ./export(noshow-sensitive) still writes every/container envsentry with its value: my export from 2026-10-16 14:32 holds seven, tokens and passwords included. After step 1 your ntfy and Telegram secrets are in every export. Keep exports out of git and off shared disks.
Steps#
Put the sink settings into the envlist. Remove old keys, then add one key per setting. Leave out any key you don't use: the script skips ntfy when
OBSERVER_NTFY_URLis empty and Telegram when either Telegram key is empty.ssh <ROUTER> '/container/envs/remove [find list=observer key~"OBSERVER_(NTFY|TELEGRAM)"]; /container/envs/add list=observer key=OBSERVER_NTFY_URL value="<NTFY_URL>"; /container/envs/add list=observer key=OBSERVER_NTFY_TOKEN value="<NTFY_TOKEN>"; /container/envs/add list=observer key=OBSERVER_TELEGRAM_TOKEN value="<TG_TOKEN>"; /container/envs/add list=observer key=OBSERVER_TELEGRAM_CHAT value="<CHAT_ID>"'A line typed by hand is saved in your shell history with the tokens in it. My installer builds the same command from a gitignored env file instead. Check the keys exist without printing their values (I set three: no ntfy token):
$ ssh <ROUTER> ':put [:len [/container/envs/find list=observer key~"OBSERVER_(NTFY|TELEGRAM)"]]' 3Expected: the number of keys you added.
Save the watchdog as
ups-watchdog.rsc. It holds no secrets.ups-notifysends the global$upsMsgto every configured sink;ups-watchdogbuilds the list of stopped or missing containers and callsups-notifywhen that list changes. The file, unchanged:# Router-side watchdog: alerts when the observer or mosquitto container stops (the observer can't # report its own death). No secrets in this file: ups-notify reads the ntfy/Telegram settings from # envlist "observer" (OBSERVER_NTFY_URL, OBSERVER_NTFY_TOKEN, OBSERVER_TELEGRAM_TOKEN, OBSERVER_TELEGRAM_CHAT). # ups-notify sends the global $upsMsg to every configured sink # ups-watchdog every 1 min (scheduler); one message per change in which containers are down; # skipped during the first 3 min after boot # Usage: /import file-name=usb1-part1/ups-watchdog.rsc # Test: :global upsMsg "router: test"; /system/script/run ups-notify # Rollback: /system/scheduler/remove [find name=ups-watchdog]; /system/script/remove [find name~"^ups-(watchdog|notify)\$"] /system/scheduler/remove [find name=ups-watchdog] /system/script/remove [find name~"^ups-(watchdog|notify)\$"] /system/script/add name=ups-notify policy=read,write,test source={ :global upsMsg :local env do={ :local id [/container/envs/find list=observer key=$1] :if ([:len $id]=0) do={:return ""} :return [/container/envs/get $id value] } :local ntfy [$env "OBSERVER_NTFY_URL"] :local ntfyTok [$env "OBSERVER_NTFY_TOKEN"] :local tg [$env "OBSERVER_TELEGRAM_TOKEN"] :local chat [$env "OBSERVER_TELEGRAM_CHAT"] :if ($ntfy != "") do={ :local hdr "Title: ups-watchdog,Priority: 4" :if ($ntfyTok != "") do={:set hdr ($hdr . ",Authorization: Bearer " . $ntfyTok)} :do {/tool/fetch url=$ntfy http-method=post http-data=$upsMsg http-header-field=$hdr output=none} on-error={:log warning "ups-notify: ntfy failed"} } :if ($tg != "" && $chat != "") do={ :local body ("{\"chat_id\":\"" . $chat . "\",\"text\":\"" . $upsMsg . "\"}") :do {/tool/fetch url=("https://api.telegram.org/bot" . $tg . "/sendMessage") http-method=post http-header-field="Content-Type: application/json" http-data=$body output=none} on-error={:log warning "ups-notify: telegram failed"} } :log warning ("ups-notify: " . $upsMsg) } /system/script/add name=ups-watchdog policy=read,write,test source={ :global upsWdState :global upsMsg :if ([:typeof $upsWdState]="nothing") do={:set upsWdState ""} :if ([/system/resource/get uptime] >= 3m) do={ :local down "" :foreach n in={"observer";"mosquitto"} do={ :local id [/container/find name=$n] :if ([:len $id]=0) do={:set down ($down . $n . " missing; ")} else={ :if ([/container/get $id running]!=true) do={:set down ($down . $n . " not running; ")} } } :if ($down != $upsWdState) do={ :set upsMsg "router: observer and mosquitto running again" :if ($down != "") do={:set upsMsg ("router: " . $down)} /system/script/run ups-notify :set upsWdState $down } } } /system/scheduler/add name=ups-watchdog interval=1m start-time=startup policy=read,write,test on-event="/system/script/run ups-watchdog" :put "ups-watchdog installed"To adapt: the envlist name (line 16), the container names (line 42) and the "running again" text (line 49). The first two lines after the header remove an earlier install, so re-importing the file replaces it.
Copy the file to the router disk. My disk is
usb1-part1:scp ups-watchdog.rsc <ROUTER>:/usb1-part1/Dry-run the import and read the last line.
$ ssh <ROUTER> '/import file-name=usb1-part1/ups-watchdog.rsc dry-run verbose=yes' … #line 57 /system/scheduler/add name=ups-watchdog interval=1m start-time=startup policy=read,write,test on-event="/system/script/run ups-watchdog" #line 58 :put "ups-watchdog installed" No syntax errors found in the import fileExpected: the last line is
No syntax errors found in the import file. In a script, test for that line. Do not grep forsyntax error: the success line contains it (Failure modes, row 1).Import.
$ ssh <ROUTER> '/import file-name=usb1-part1/ups-watchdog.rsc' ups-watchdog installed Script file loaded and executed successfullyExpected: both lines. The router logs each
script addwith the full script source (system,info new script added by ssh-cmd:…), which is another reason to keep secrets out of the source.Check the scheduler entry. Right after my import:
$ ssh <ROUTER> '/system/scheduler/print detail where name=ups-watchdog' 0 name="ups-watchdog" start-time=startup interval=1m on-event=/system/script/run ups-watchdog owner="admin" policy=read,write,test run-count=0 next-run=2026-10-16 14:32:05Expected:
interval=1m,start-time=startupand anext-runwithin the next minute.Send a test message through the same path a real alert takes.
$ ssh <ROUTER> ':global upsMsg "router: ups-watchdog installed (test)"; /system/script/run ups-notify; :delay 5s; /log/print where message~"ups-notify"' … 2026-10-16 14:31:08 script,warning ups-notify: router: ups-watchdog installed (test)Expected: the
script,warning ups-notify:line with your text, and the message on each sink. On mine the log also had2026-10-16 14:31:07 fetch,info Download from api.telegram.org FINISHEDone second earlier. The message arrived on ntfy, and in the Telegram chat as (bot name replaced):[10/16/26 2:31 PM] <BOT>: router: ups-watchdog installed (test)No log line from
ups-notifyother than the final one means no fetch failed. A failed fetch logsups-notify: ntfy failedorups-notify: telegram failed(lines 27 and 31).Stop a watched container and start it again.
[UNVERIFIED: down path not triggered yet]Stopping a container stops its service (myobserveris the NUT server for the LAN hosts), so do it while on grid power. Keep the container stopped for at least 70 s: the scheduler checks once a minute, and a shorter stop can fall between two checks and go unreported. Howrestart-policy=alwaystreats a manual stop was not checked.ssh <ROUTER> '/container/stop [find name=observer]'Expected, within two minutes (one scheduler run after the container has stopped): a message
router: observer not running;on both sinks, built by lines 45 and 50, and a log lineups-notify: router: observer not running;. Then:ssh <ROUTER> '/container/start [find name=observer]'Expected, within two minutes after the container is running:
router: observer and mosquitto running again(line 49). I have not run this step; the expected messages come from reading the script, not from a run.
Verify#
The scheduler runs once a minute and the watchdog's state is empty (no watched container down). From my router, about ten hours after the install:
$ ssh <ROUTER> '/system/scheduler/print detail where name=ups-watchdog'
0 name="ups-watchdog" start-time=startup interval=1m
on-event=/system/script/run ups-watchdog owner="admin"
policy=read,write,test run-count=594 next-run=2026-10-17 00:26:05
$ ssh <ROUTER> '/system/script/environment/print'
Columns: NAME, VALUE
# NAME VALUE
0 upsMsg router: ups-watchdog installed (test)
1 upsWdState
Expected: run-count grows by one per minute (594 runs from the first at 14:32:05 to 00:25:05), and upsWdState is empty while every watched container runs. When one is down it holds the same text as the last alert, for example observer not running; . Read the state with environment/print: :global upsWdState typed in a new SSH session prints nothing (field notes, 2026-10-17).
Undo / rollback#
[UNVERIFIED: rollback not exercised] From the .rsc header and my installer:
ssh <ROUTER> '/system/scheduler/remove [find name=ups-watchdog]; /system/script/remove [find name~"^ups-(watchdog|notify)\$"]'
ssh <ROUTER> '/container/envs/remove [find list=observer key~"OBSERVER_(NTFY|TELEGRAM)"]'
The second line also removes the alert settings from any container that uses the same envlist, from its next re-create on. Skip it if a container sends its own alerts from these keys. The globals upsMsg and upsWdState stay in /system/script/environment until a reboot.
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
No syntax errors found in the import file, followed by my installer's ABORT: dry-run found errors | The installer's error check was a case-insensitive grep for patterns including syntax error, which matches the success line | Treat only No syntax errors found as success (step 4) |
ntfy shows the test, Telegram seems to have nothing, while the router log has fetch,info Download from api.telegram.org FINISHED | Not a send failure: the message was in the bot chat when I opened it; I had only looked at notifications | Open the chat itself before debugging the token or chat id |
Field notes#
- 2026-10-17 00:26 router time (21:26 UTC), hAP ax^3, RouterOS 7.24.4 (read-only checks): both containers running; scheduler at
run-count=594;upsWdStateempty in/system/script/environment; the onlyups-notifywarning in the log is the test from the install, so no watched container stayed stopped through a scheduler run in those ten hours. Theobservercontainer was re-created twice in that window (14:32:46–14:32:54 and 23:55:45–23:55:52 router time) and no alert went out: both gaps fell between two runs, which tick at :05 each minute.:global upsWdState; :put [:typeof $upsWdState]from a new SSH session printednothing, whileenvironment/printshows the scheduler's empty string: the script sets it to empty on its first run only, and to the new state on each change. - 2026-10-16 14:26–14:31 router time (UTC+3), hAP ax^3, RouterOS 7.24.4 (install): my installer first stopped at the dry run with
No syntax errors found in the import fileon screen followed by its ownABORT; its error grep matched the success line. I changed the check to require the success line and re-ran it. At 14:31:06 the router logged both scripts and the scheduler as added, at 14:31:07 the Telegram fetchFINISHED, at 14:31:08 theups-notifywarning with the test text. The test arrived on ntfy. I thought the Telegram message was missing until I opened the bot chat: it was there, stamped 2:31 PM, with the observer's own startup alert a minute later. The stop/start test (step 8) was not run.
Notes#
- No retry.
ups-notifymakes one fetch per sink. A failed fetch is logged, andups-watchdogstores the new state anyway (line 52), so an alert sent while the uplink is down is lost; the next message goes out only at the next change. The alerts my observer sends itself retry until delivered; why both exist: The alert about the blackout needs the internet too. - Fixed messages only. The Telegram body is built by string concatenation (line 30): a
"or\in$upsMsgbreaks the JSON./tool/fetchtakes headers as a comma-separated list (http-header-field="h1:fff,h2:yyy"), so a comma in the ntfy title or token would split the header. - Short stops go unreported. The check runs once a minute; a stop and restart between two runs (a re-create took under 10 s here) sends nothing. Twice observed on 2026-10-16.
- Policy.
read,write,testis what I deployed for the scripts and the scheduler. I have not tested a narrower policy, and my SSH user wasadmin(full). - After a reboot the scheduler starts 3 s after startup (MikroTik's scheduler docs), the globals start empty, and the watchdog does nothing until uptime reaches 3 minutes. A watched container that is not running by then is reported as down.
- ntfy
Priority: 4is "high" in ntfy's table (long vibration burst, pop-over notification). - Re-creating a container with new envlist values: Update a RouterOS 7 container from a new image tar. Other RouterOS container traps: Six gotchas deep in RouterOS containers. Message wording: Designing alerts you'll actually understand at 3am.
Filed under: mikrotik, routeros, containers, monitoring
Sources#
- MikroTik: Fetch —
http-method,http-data(64 KB limit),http-header-fieldlist format,output=none. - MikroTik: Container —
/container/envs/add list= key= value=andenvlist. - MikroTik: Scripting — global variables in the environment,
do {…} on-error={…}. - MikroTik: Scheduler —
start-time=startupruns 3 s after boot; a reboot resetsrun-count. - ntfy: Sending messages — POST body as the message,
TitleandPriorityheaders, priority levels,Authorization: Bearer tk_…. - Telegram Bot API —
sendMessagewithchat_idandtext(1–4096 characters), JSON request bodies.