Manual

Record MQTT topics to a MikroTik router's SSD

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), image eclipse-mosquitto:2.1.2-alpine pulled from Docker Hub, broker in another container on the same container bridge (2026-11-02). The stop-signal behaviour of run.sh was 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 mqttrec later 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:

    PlaceholderMeaning (mine)
    <ROUTER>SSH user@address of 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-image pulls from the registry set in /container/config, and MikroTik's documented default is https://lscr.io/. Check registry-url in ssh <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.sh in 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#

  1. 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.rsc
    

    Check 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 ones run.sh reads; 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.

  2. 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
    done
    

    What 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 3600 makes mosquitto_sub disconnect 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 60 is the keepalive; 60 s is also the default.
    • mosquitto_sub runs in the background with wait, and the trap kills it on TERM. Without that, sh is PID 1 and ignores SIGTERM: in Docker a stop then waits 10 s and kills the container, exit code 137 (Failure modes, row 3).
    • Errors from mosquitto_sub go to stderr, which the router's log shows when the container has logging=yes.
  3. Upload run.sh to the directory the container will mount.

    scp run.sh "<ROUTER>:/<DISK>/mqtt-recorder/run.sh"
    

    Expected: /<DISK>/mqtt-recorder/run.sh is listed by ssh <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.

  4. 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 :local values 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-recorder
    

    The image's root goes under the same directory as run.sh and the logs; only /<DISK>/mqtt-recorder is mounted into the container, as /rec. Keep restart-policy=always and start-on-boot=yes so the recorder comes back after a crash or a router reboot. No NAT rule is needed: the container only connects out to the broker.

  5. 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. Without verbose=yes RouterOS 7.24 refuses the dry run (Failure modes, row 1). Then import for real, without verbose=yes dry-run:

    ssh <ROUTER> '/import file-name=<DISK>/mqtt-recorder-setup.rsc'
    

    The add starts the image pull and extraction into root-dir.

  6. 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]. If start fails 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.

  7. Read the container's log once.

    ssh <ROUTER> '/log/print where topics~"container" and message~"mqtt-recorder"'
    

    One Error: Bad file descriptor right 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 moment mosquitto_sub was 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)CauseFix
dry-run parameter cannot be used in non-verbose mode on /import ... dry-runRouterOS 7.24 allows dry-run only in verbose modeAdd 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 startThe first connect ran before the container's veth was upNone: 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 137sh as PID 1 ignores SIGTERM while mosquitto_sub runs in the foregroundRun 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.sh whose broker address pointed at it. A copy without the trap, mosquitto_sub in the foreground, run under timeout 30 docker run: the client was still attached after 30 s, and timeout only returned (code 124) after I stopped the container from another shell; that docker stop took 10 s and the exit code was 137. The deployed version: one test message landed in mqtt-2026-11-02.log as 2026-11-02T10:16:45+0000 ups/test/state {"ok":true} (UTC inside the container here), and docker stop took 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 printed Error: Bad file descriptor (exit code 1) for a broker address with no host behind it, and Connection 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-run failed with the verbose-mode error; with verbose=yes dry-run it passed. After the import the container log had one Error: 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-key mqttrec list, 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 run came back, and I had to remove it with docker rm -f. With the trap, docker stop took 0 s, exit code 0. The 12:13 re-run above did not reproduce timeout returning: 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), -F with %I (ISO-8601 date and time), %t (topic), %p (payload).
  • MikroTik: Container: remote-image with /container/config registry-url (default https://lscr.io/, Docker Hub example https://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 after add.
  • MikroTik: Configuration Management: /import verbose and dry-run ("This option is only available in verbose mode"); /export show-sensitive.
  • Docker Hub: eclipse-mosquitto 2.1.2-alpine: the tag, with arm64 and arm images.