Record MQTT topics to a MikroTik router's SSD
Contents
This runs mosquitto_sub in a RouterOS 7.24 container and appends every message on a set of MQTT topics to a file on the router's SSD, one file per UTC day, with a timestamp on each line.
- Tested on
- a MikroTik router with RouterOS 7.24, the container package and a USB SSD (
usb1-part1), imageeclipse-mosquitto:2.1.2-alpinepulled from Docker Hub, broker in another container on the same container bridge (2026-11-02). The stop-signal behaviour ofrun.shwas re-checked in Docker 29.9.2 on Fedora 44 the same day. - Not verified
- the rollback; the day switch of the file name (the recorder has run for less than a day); this exact order (my first deploy named another container's envlist, and I moved the running container to
mqttreclater the same day); and the commands of steps 1, 3 and 6 as written here: my notes record what each step did, not these exact lines. - Needs
- a container bridge with a gateway address on the router and an MQTT broker reachable from it; the router's container registry set to Docker Hub; a read-only MQTT user; SSH and SCP to the router; an external disk with free space.
- Time
- 20 min plus the image pull.
- Risk
- low. The container only opens an outbound MQTT connection and writes to one directory. Nothing rotates or deletes the files: about 3.5 to 5 MB a day, so a large SSD lasts decades, a small USB stick does not. Rollback removes one container, one mount, one envlist, one bridge port and one veth.
Before you start#
Placeholders used below:
Placeholder Meaning (mine) <ROUTER>SSH user@addressof the router, plus your own-i <KEY>options<DISK>the external disk ( usb1-part1)<CT_BRIDGE>the bridge your containers' veths are ports of ( bridge-containers)<CT_ADDR>/<CT_GW>a free address on that bridge for the recorder, and the bridge's gateway ( 172.17.0.5,172.17.0.1)<BROKER_IP>the broker's address as seen from the container bridge ( 172.17.0.3)<MQTT_USER>/<MQTT_PASSWORD>the read-only MQTT login the recorder uses The registry is Docker Hub.
remote-imagepulls from the registry set in/container/config, and MikroTik's documented default ishttps://lscr.io/. Checkregistry-urlinssh <ROUTER> '/container/config/print'; if it isn't Docker Hub, set it as MikroTik's page shows:/container/config/set registry-url=https://registry-1.docker.io.The broker accepts the read-only user and lets it subscribe to the topics you put into
run.shin step 2.Backup: take a config export before step 1 (
/export file=<DISK>/backups/<NAME>) and copy it off the router.
WarningExports carry envlist values
On RouterOS 7.24, /export prints every /container/envs value in plain text even without show-sensitive (see Update a RouterOS 7 container from a new image tar). After step 1 that includes the MQTT password. Store exports like password files.
Steps#
Create an envlist that holds only the MQTT login. Write the two lines to a local file, upload it, import it, then delete it on the router and locally:
/container/envs/add list=mqttrec key=OBSERVER_MQTT_USER value="<MQTT_USER>" /container/envs/add list=mqttrec key=OBSERVER_MQTT_PASSWORD value="<MQTT_PASSWORD>"chmod 600 mqttrec-env.rsc scp mqttrec-env.rsc "<ROUTER>:/<DISK>/" ssh <ROUTER> '/import file-name=<DISK>/mqttrec-env.rsc' ssh <ROUTER> '/file/remove [find name="<DISK>/mqttrec-env.rsc"]' rm mqttrec-env.rscCheck the two keys without printing their values:
ssh <ROUTER> ':put [:len [/container/envs/find list=mqttrec]]'. Expected:2[UNVERIFIED: not run in this form]. The key names are the onesrun.shreads; if you rename them, rename them there too.GotchaDon't reuse another container's envlist
A container gets every variable in each list it names. My first deploy reused the envlist of another container on the router to avoid a new secret; that list also held alert-bot tokens and other passwords, so a stock Docker Hub image could read all of them. I switched it to this two-key list the same day.
Write
run.sh. This is the script as deployed, without its header comments. Change the broker address and the topics (-t) to yours:mkdir -p /rec/log # sh as PID 1 ignores SIGTERM: forward it, or a container stop waits for the kill timeout trap 'kill $pid 2>/dev/null; exit 0' TERM INT while :; do f=/rec/log/mqtt-$(date -u +%Y-%m-%d).log mosquitto_sub -h 172.17.0.3 -u "$OBSERVER_MQTT_USER" -P "$OBSERVER_MQTT_PASSWORD" -k 60 -W 3600 \ -F '%I %t %p' -t 'ups/+/state' -t 'ups/+/bridge' -t 'ups/ac180/info' -t 'ups/ac180/log' >> "$f" & pid=$! wait $pid sleep 1 doneWhat each part does:
-F '%I %t %p'prints an ISO-8601 timestamp in the container's time zone (with its offset), the topic and the raw payload on one line.-W 3600makesmosquitto_subdisconnect an hour after it connects. Only then does the loop compute the file name again, so the switch to the next day's file happens at the first reconnect after 00:00 UTC, up to an hour late: lines from the last hour before that reconnect still go into the previous day's file. Cost: about one second without a subscriber per hour, and retained messages are written again after every reconnect.-k 60is the keepalive; 60 s is also the default.mosquitto_subruns in the background withwait, and thetrapkills it onTERM. Without that,shis PID 1 and ignoresSIGTERM: in Docker a stop then waits 10 s and kills the container, exit code 137 (Failure modes, row 3).- Errors from
mosquitto_subgo to stderr, which the router's log shows when the container haslogging=yes.
Upload
run.shto the directory the container will mount.scp run.sh "<ROUTER>:/<DISK>/mqtt-recorder/run.sh"Expected:
/<DISK>/mqtt-recorder/run.shis listed byssh <ROUTER> '/file/print where name~"mqtt-recorder"'.[UNVERIFIED: whether scp creates a missing mqtt-recorder directory on RouterOS 7.24]If the file is not listed, create the directory on the disk first (WinBox or WebFig, Files) and upload again.Write the setup script
mqtt-recorder-setup.rsc. This is my script file as it stands after the envlist switch, minus two comment lines about the envlist; set the:localvalues to yours:# MQTT value recorder: mosquitto_sub on the router writes every ups/* message to the SSD # (/usb1-part1/mqtt-recorder/log/mqtt-YYYY-MM-DD.log). The router stays up through outages. # Router-side state: veth 172.17.0.5, bridge port, mount, container. No NAT (outbound to the broker only). :local disk "usb1-part1" :local ctBridge "bridge-containers" :local ctAddr "172.17.0.5" :local ctGw "172.17.0.1" :local image "library/eclipse-mosquitto:2.1.2-alpine" /interface/veth/add name=veth-mqttrec address=($ctAddr . "/24") gateway=$ctGw comment=mqtt-recorder /interface/bridge/port/add bridge=$ctBridge interface=veth-mqttrec comment=mqtt-recorder /container/mounts/add list=mqtt-recorder src=("/" . $disk . "/mqtt-recorder") dst=/rec comment=mqtt-recorder /container/add name=mqtt-recorder remote-image=$image interface=veth-mqttrec \ root-dir=("/" . $disk . "/mqtt-recorder/root") mountlists=mqtt-recorder envlists=mqttrec \ cmd="/bin/sh /rec/run.sh" \ start-on-boot=yes restart-policy=always restart-interval=10s logging=yes memory-high=32MiB comment=mqtt-recorderThe image's root goes under the same directory as
run.shand the logs; only/<DISK>/mqtt-recorderis mounted into the container, as/rec. Keeprestart-policy=alwaysandstart-on-boot=yesso the recorder comes back after a crash or a router reboot. No NAT rule is needed: the container only connects out to the broker.Upload the script, dry-run it, then import it.
scp mqtt-recorder-setup.rsc "<ROUTER>:/<DISK>/" ssh <ROUTER> '/import file-name=<DISK>/mqtt-recorder-setup.rsc verbose=yes dry-run'Expected:
No syntax errors found in the import file. Withoutverbose=yesRouterOS 7.24 refuses the dry run (Failure modes, row 1). Then import for real, withoutverbose=yes dry-run:ssh <ROUTER> '/import file-name=<DISK>/mqtt-recorder-setup.rsc'The
addstarts the image pull and extraction intoroot-dir.Start the container once the image is extracted. MikroTik's page: "the Container itself will not be started after it has been added, you need to start it manually for the first time after it has been downloaded/extracted."
ssh <ROUTER> '/container/start [find name=mqtt-recorder]' ssh <ROUTER> ':put [/container/get [find name=mqtt-recorder] running]'Expected:
true[UNVERIFIED: not run in this form]. Ifstartfails while the pull is still running, wait and repeat; Update a RouterOS 7 container from a new image tar has a retry loop for this.Read the container's log once.
ssh <ROUTER> '/log/print where topics~"container" and message~"mqtt-recorder"'One
Error: Bad file descriptorright after the start is harmless: the first connect ran before the veth was up, and the loop connected one second later (Failure modes, row 2).
Verify#
- A file named after today's UTC date exists in
/<DISK>/mqtt-recorder/log/and grows:ssh <ROUTER> '/file/print where name~"mqtt-recorder/log"', twice a few minutes apart. Mine grew about 2.4 KB a minute with two stations publishing every 20 s. - Its lines carry the router's local time and the raw payload. My first line, payload cut here:(
2026-11-02T11:58:59+0200 ups/dji2000/state {"ok":true,...}...marks my cut, not the file.) The timestamp is in the container's time zone with its offset (on my router, the router's local time, UTC+2), while the file name uses the UTC date of the momentmosquitto_subwas started (step 2).
Undo / rollback#
[UNVERIFIED: rollback not exercised]
/container/stop [find name=mqtt-recorder]
/container/remove [find name=mqtt-recorder]
/container/mounts/remove [find list=mqtt-recorder]
/container/envs/remove [find list=mqttrec]
/interface/bridge/port/remove [find interface=veth-mqttrec]
/interface/veth/remove [find name=veth-mqttrec]
Wait for the stop to finish before remove. The recorded logs, run.sh and the extracted image stay in /<DISK>/mqtt-recorder/, and mqtt-recorder-setup.rsc stays in /<DISK>/; delete them with /file/remove if you don't want them.
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
dry-run parameter cannot be used in non-verbose mode on /import ... dry-run | RouterOS 7.24 allows dry-run only in verbose mode | Add verbose=yes (step 5). Success prints No syntax errors found in the import file. |
One Error: Bad file descriptor in the container log right after the start | The first connect ran before the container's veth was up | None: the loop retries after sleep 1. If it repeats every second, the broker address is not reachable from the veth: the same image prints exactly this message for a host that does not answer. A refused login prints Connection error: Connection Refused: not authorised instead. |
In Docker: timeout 30 docker run ... /bin/sh /rec/run.sh does not stop the container; docker stop takes 10 s and the exit code is 137 | sh as PID 1 ignores SIGTERM while mosquitto_sub runs in the foreground | Run mosquitto_sub with & and wait, and trap TERM (step 2). After that docker stop returns in about a second with exit code 0. On RouterOS, stop-signal (default 15) is, per MikroTik's page, the "Type of Linux signal to send when container was not stopped after 10 seconds" [UNVERIFIED: how RouterOS 7.24 stops a container whose PID 1 ignores SIGTERM]. |
Field notes#
- 2026-11-02 12:13-12:17 EET, workstation, Fedora 44, Docker 29.9.2, eclipse-mosquitto 2.1.2-alpine (mosquitto_sub 2.1.2, libmosquitto 2.1.0): re-check of the signal handling against a throwaway broker on a private Docker network, with copies of
run.shwhose broker address pointed at it. A copy without the trap,mosquitto_subin the foreground, run undertimeout 30 docker run: the client was still attached after 30 s, andtimeoutonly returned (code 124) after I stopped the container from another shell; thatdocker stoptook 10 s and the exit code was 137. The deployed version: one test message landed inmqtt-2026-11-02.logas2026-11-02T10:16:45+0000 ups/test/state {"ok":true}(UTC inside the container here), anddocker stoptook 1 s by a whole-second timer, exit code 0. Later the same day, against a throwaway broker on an internal Docker network, the same image printedError: Bad file descriptor(exit code 1) for a broker address with no host behind it, andConnection error: Connection Refused: not authorised(exit code 5) for a refused login. - 2026-11-02, MikroTik router, RouterOS 7.24, eclipse-mosquitto 2.1.2-alpine: on the router,
/import ... dry-runfailed with the verbose-mode error; withverbose=yes dry-runit passed. After the import the container log had oneError: Bad file descriptor, the loop connected a second later, and the first line was written at 11:58:59 router time. The file grew about 2.4 KB a minute; the SSD had 58 GB free. The first deploy used the envlist of my NUT observer container, which also holds alert tokens and other passwords; the same day I moved the recorder to the two-keymqttreclist, uploaded as a chmod-600.rsc, imported, and deleted on the router. - 2026-11-02, workstation, Docker, eclipse-mosquitto 2.1.2-alpine (earlier local test before the router deploy): an earlier version of the script without the trap kept the container running after
timeout 30 docker runcame back, and I had to remove it withdocker rm -f. With the trap,docker stoptook 0 s, exit code 0. The 12:13 re-run above did not reproducetimeoutreturning: there the client stayed attached.
Notes#
- Size: 2.4 KB/min is about 3.5 MB a day; my own estimate was ~5 MB a day. No full day had been recorded when I wrote this. At either rate 58 GB lasts decades, so I added no rotation.
- Why this exists: my router's own log has state changes and alerts only, and the hosts on the power stations die in an outage, while the router stays up. Three shutdowns, three causes is the outage that showed the gap.
- The broker and the other containers on this router: A null battery reading cleared the low-battery alarm. Container quirks on RouterOS: Six gotchas deep in RouterOS containers. To get a message when the recorder stops: Alert on a stopped RouterOS container via ntfy and Telegram.
Filed under: mikrotik, routeros, containers, mqtt, logging
Sources#
- mosquitto_sub(1):
-W("The client will stop processing messages and disconnect after this number of seconds has passed. The timeout starts just after the client has connected to the broker."),-k(default 60 s),-Fwith%I(ISO-8601 date and time),%t(topic),%p(payload). - MikroTik: Container:
remote-imagewith/container/configregistry-url(defaulthttps://lscr.io/, Docker Hub examplehttps://registry-1.docker.io), veth, mounts, envlists,logging,start-on-boot,memory-high,stop-signal(default 15, "Type of Linux signal to send when container was not stopped after 10 seconds"), and that a container is not started afteradd. - MikroTik: Configuration Management:
/importverboseanddry-run("This option is only available in verbose mode");/exportshow-sensitive. - Docker Hub: eclipse-mosquitto 2.1.2-alpine: the tag, with arm64 and arm images.