Manual

Update a RouterOS 7 container from a new image tar

This replaces the image of a running RouterOS 7 container with a new arm64 build. The container keeps its name, veth, mounts, envlist and NAT rule, and the router keeps the previous tar on disk for a rollback.

Tested on
MikroTik hAP ax^3, RouterOS 7.24.4 (stable), arm64, container files on a USB SSD (usb1-part1); image built on Fedora 44 with docker-ce 29.9.1 and docker-buildx-plugin 0.38.0 (2026-10-16, three updates).
Needs
a container that already runs from a file-imported tar with root-dir on an external disk; SSH and SCP access to the router as a user with write policy; Docker with buildx on the build machine.
Time
10 min.
Risk
the container's service is down while it is re-created (about 18 s for the one NUT client I timed, on the first run). The config export holds the container's environment variables in plain text. Anything the container writes outside its mounts is lost: each update starts from a fresh root-dir, and the old one disappears. Rollback is a re-import of the previous version's script, which has not been tested.

Before you start#

  • Placeholders used below:
    • <ROUTER> — SSH user and address of the router, such as admin@<ROUTER_IP>. Add your -i <KEY> option to every ssh and scp line.
    • <NAME> — the container's name (mine: observer).
    • <DISK> — the external disk (mine: usb1-part1). <DIR> — the container's directory on it, without a leading slash (mine: usb1-part1/observer).
    • <IMAGE> — the image name and tar base name (mine: nut-observer).
    • <VER> — the new version label. <PREV> — the version it replaces.
    • <LIST> — the container's envlist (and mountlist) name. <KEY> / <VALUE> — an environment variable to set.
    • <BUILD_CONTEXT> — the directory with your Dockerfile and binary.
  • The container exists, and exactly once:
    $ ssh <ROUTER> ':put [:len [/container/find name=observer]]'
    1
    
    Expected: 1. 0 means there is nothing to update; create the container first.
  • Write down every parameter of the current container (/container/print detail where name=<NAME>, or the script you created it with). Step 5 has to repeat all of them; whatever you leave out is lost on re-create.
  • Backup: step 2 takes a config export before anything changes.

Steps#

  1. Build an arm64 docker-save tar with uncompressed layers.

    docker buildx build --platform linux/arm64 \
      --output "type=docker,dest=<IMAGE>.tar,name=<IMAGE>:<VER>,compression=uncompressed,force-compression=true" \
      <BUILD_CONTEXT>
    tar xOf <IMAGE>.tar manifest.json; echo; ls -l <IMAGE>.tar
    

    My build for eb59246 (one COPY of a static binary into FROM scratch):

    $ docker buildx build --platform linux/arm64 \
        --output "type=docker,dest=router/observer/nut-observer.tar,name=nut-observer:eb59246,compression=uncompressed,force-compression=true" <BUILD_CONTEXT>
    #0 building with "default" instance using docker driver
    …
    #4 [1/1] COPY nut-observer /nut-observer
    #4 DONE 0.0s
    
    #5 exporting to docker image format
    #5 exporting layers
    #5 exporting layers 0.2s done
    #5 exporting manifest sha256:7218bea8a7911d4cb6875f74a93f350177f031a2a3df72618e2b250d66ab7b19 0.0s done
    #5 exporting config sha256:7d351d5ee194596f0cf658da72d68e7de41ba71eb31343842a07ac01ad912491 0.0s done
    #5 sending tarball 0.1s done
    #5 DONE 0.3s
    $ tar xOf router/observer/nut-observer.tar manifest.json; echo; ls -l router/observer/nut-observer.tar
    [{"Config":"blobs/sha256/7d351d5ee194596f0cf658da72d68e7de41ba71eb31343842a07ac01ad912491","RepoTags":["nut-observer:eb59246"],"Layers":["blobs/sha256/610386223fecb3f4e631488ca51c5d440fa5384213b16ca9d802915e86cb98eb"]}]
    -rw-------. 1 user user 6498816 Oct 16 14:32 router/observer/nut-observer.tar
    

    Expected: RepoTags carries <IMAGE>:<VER> and Layers lists your layers. To confirm a layer is stored uncompressed, read its first two bytes. Here on the later 3f4f7db tar:

    $ tar xOf router/observer/nut-observer.tar blobs/sha256/92a99037ad91a106e2190df5ebe01a9f9d25ed5dc133dec5af0d733712493314 | head -c 2 | od -An -tx1
     6e 75
    

    6e 75 is nu, the start of the file name nut-observer in a plain tar header. A gzip stream starts with 1f 8b.

  2. Export the router config and copy it off the router.

    TAG=$(date +%F)-pre-<NAME>-<VER>
    ssh <ROUTER> "/export file=<DISK>/backups/$TAG"
    mkdir -p "backups/$TAG"
    scp "<ROUTER>:/<DISK>/backups/$TAG.rsc" "backups/$TAG/"
    chmod 700 "backups/$TAG"; chmod 600 "backups/$TAG/"*
    

    The local copies from my three runs, after the chmod (my three runs predate it and had left them -rw-r--r--):

    $ ls -l router/backups/2026-10-16-pre-observer-*/
    router/backups/2026-10-16-pre-observer-27e8b5d/:
    total 16
    -rw-------. 1 user user 16074 Oct 16 13:23 2026-10-16-pre-observer-27e8b5d.rsc
    
    router/backups/2026-10-16-pre-observer-3f4f7db/:
    total 20
    -rw-------. 1 user user 19132 Oct 16 23:55 2026-10-16-pre-observer-3f4f7db.rsc
    
    router/backups/2026-10-16-pre-observer-eb59246/:
    total 20
    -rw-------. 1 user user 19132 Oct 16 14:32 2026-10-16-pre-observer-eb59246.rsc
    $ head -1 router/backups/2026-10-16-pre-observer-eb59246/2026-10-16-pre-observer-eb59246.rsc
    # 2026-10-16 14:32:46 by RouterOS 7.24.4
    

    Expected: a non-empty .rsc whose first line names your RouterOS version. Store it like a password file: without show-sensitive the export still contains every /container envs entry with its value (on 7.24.4, all seven of mine, passwords and tokens included). Keep the directory out of git and off shared disks. The /export file= also leaves a plaintext copy on the router's disk: delete it after the scp, or make sure that directory isn't shared (RouterOS auto-shares new USB disks over SMB; on mine only the media folder is shared).

  3. Set or refresh environment variables before the re-create. Put new or changed variables into the envlist before step 6, so the new container starts with them. [UNVERIFIED: the order comes from my script; whether a running container picks up envlist changes without a re-create was not tested]

    ssh <ROUTER> '/container/envs/remove [find list=<LIST> key=<KEY>]; /container/envs/add list=<LIST> key=<KEY> value="<VALUE>"'
    

    Check that the key exists without printing its value:

    $ ssh <ROUTER> ':put [:len [/container/envs/find list=observer key=OBSERVER_PLUG_PASSWORD]]'
    1
    

    Expected: 1. Skip this step if no variable changes.

  4. Upload the tar under a versioned name. The old tar stays on the disk as rollback material.

    scp <IMAGE>.tar "<ROUTER>:/<DIR>/<IMAGE>-<VER>.tar"
    

    Upload any files the container reads from its mounts in the same step. Check the disk:

    $ ssh <ROUTER> '/file/print where name~"observer/nut-observer"'
    Columns: NAME, TYPE, SIZE, LAST-MODIFIED
    # NAME                                  TYPE       SIZE    LAST-MODIFIED      
    0 usb1-part1/observer/nut-observer-eb5  .tar file  6.2MiB  2026-10-16 14:32:48
      9246.tar                                                                    
    1 usb1-part1/observer/nut-observer-27e  .tar file  6.1MiB  2026-10-16 13:23:21
      8b5d.tar                                                                    
    2 usb1-part1/observer/nut-observer-3f4  .tar file  6.2MiB  2026-10-16 23:55:46
      f7db.tar                                                                    
    3 usb1-part1/observer/nut-observer.tar  .tar file  6.0MiB  2026-10-15 23:04:11
    

    Expected: <IMAGE>-<VER>.tar is listed with the size of your local tar. This listing is from about ten minutes after the third update: every tar is still there.

  5. Write the re-create script <NAME>-update-<VER>.rsc. Stop, wait, remove, then add the container again from the new tar into a new root-dir, repeating every other parameter of the original /container/add. Mine for eb59246 (generated by my update script, broker address replaced):

    # Re-creates the observer container from nut-observer-eb59246.tar (generated by update-observer.sh).
    /container/stop [find name=observer]
    :delay 5s
    /container/remove [find name=observer]
    /container/add name=observer file="usb1-part1/observer/nut-observer-eb59246.tar" interface=veth-observer \
        root-dir="/usb1-part1/observer/root-eb59246" mountlists=observer envlists=observer \
        cmd="serve -listen 0.0.0.0:3493 -broker tcp://192.0.2.3:1883 -config /config/observer.json" \
        start-on-boot=yes restart-policy=always restart-interval=10s logging=yes memory-high=48MiB comment=observer
    

    Change name, file, root-dir, interface, mountlists, envlists and cmd to yours. Keep restart-policy=always: on 7.24 the default is no, and a container that exits then stays down. The veth, the mountlist, the envlist and the NAT rule are separate objects, so removing the container leaves them in place and the add line picks them up by name. Keep a copy of this file next to your build: it is the next update's rollback.

  6. Upload and import the script.

    scp <NAME>-update-<VER>.rsc "<ROUTER>:/<DIR>/"
    ssh <ROUTER> "/import file-name=<DIR>/<NAME>-update-<VER>.rsc"
    
    $ ssh <ROUTER> '/import file-name=usb1-part1/observer/observer-update-eb59246.rsc'
    Script file loaded and executed successfully
    

    Expected: exactly that line. The service is down from here until step 7 ends.

  7. Start the container once the image is extracted. /container/add only starts the extraction; the container has to be started by hand afterwards. MikroTik's page says to wait for status=stopped, but 7.24.4 has no status property, so retry start and read running:

    for _ in $(seq 1 60); do
      run=$(ssh <ROUTER> ':do { /container/start [find name=<NAME>] } on-error={}; :delay 3s; :put [/container/get [find name=<NAME>] running]' | tr -d '\r')
      echo "running: $run"; [ "$run" = "true" ] && break; sleep 5
    done
    
    running: true
    

    Expected: running: true. In all three runs it came on the first pass. If the loop prints running: false every time, read /log/print where topics~"container" before changing anything.

Verify#

  • The container runs from the new root dir (this print is from after the 3f4f7db update):
    $ ssh <ROUTER> '/container/print where name=observer'
    Flags: R - RUNNING
    Columns: NAME, ROOT-DIR, INTERFACE, MEMORY-CURRENT, CPU-USAGE
    #   NAME      ROOT-DIR                           INTERFACE      MEMORY-  C
    ;;; observer
    1 R observer  /usb1-part1/observer/root-3f4f7db  veth-observer  10.1MiB  0
    
    Expected: flag R and root-<VER> in ROOT-DIR.
  • The container's log shows the new process starting (needs logging=yes). Cut from the eb59246 run; router clock UTC+3, container clock UTC:
    $ ssh <ROUTER> '/log/print where topics~"container" and message~"observer|nut-observer|canary|led"' | tail -25
    2026-10-16 14:33:02 container,info,debug observer: 2026/10/16 11:33:02.439785 mqtt: ups/ac180/bridge = online (retained=true)
    2026-10-16 14:33:02 container,info,debug observer: 2026/10/16 11:33:02.439785 mqtt: ups/ac180/bridge = online (retained=true)
    …
     2026-10-16 14:33:11 container,info,debug observer: 2026/10/16 11:33:11.280403 ups dji2000-t3: status "OL" fresh=true
     2026-10-16 14:33:11 container,info,debug observer: 2026/10/16 11:33:11.280403 ups dji2000-t3: status "OL" fresh=true
    
    Every line printed twice in all three runs. I have not looked into why.
  • The service answers through the unchanged NAT rule. Mine is a NUT server on port 3493:
    $ printf "LIST UPS\nLOGOUT\n" | nc -w 5 192.0.2.1 3493
    BEGIN LIST UPS
    UPS ac180-t1 "BLUETTI AC180, tier t1, via nut-observer"
    UPS ac180-t2 "BLUETTI AC180, tier t2, via nut-observer"
    UPS ac180-t3 "BLUETTI AC180, tier t3, via nut-observer"
    UPS dji2000-t1 "DJI Power 2000, tier t1, via nut-observer"
    UPS dji2000-t2 "DJI Power 2000, tier t2, via nut-observer"
    UPS dji2000-t3 "DJI Power 2000, tier t3, via nut-observer"
    END LIST UPS
    OK Goodbye
    

Undo / rollback#

[UNVERIFIED: rollback not exercised] None of the steps below has been run.

  • Rolling back to a version that this procedure installed: import that version's script. Its tar is still on the disk (step 4 listing), and its add line extracts the tar into root-<PREV> again:
    ssh <ROUTER> "/import file-name=<DIR>/<NAME>-update-<PREV>.rsc"
    
    Then run the step 7 loop.
  • Rolling back past the first update: there is no generated script for the original image. Remove the container and create it again with your original /container/add line and the original tar (mine: nut-observer.tar, still on the disk).
  • If the environment change in step 3 is what broke it, restore the old value with the same remove/add line before importing. The old value is in the step 2 export.
  • If the router config itself is in doubt, the step 2 export is a full /export: compare the container, veth, mount and NAT sections with the current /export, and fix them by hand.

Failure modes#

Symptom (exact message)CauseFix
NUT client logs Communications with UPS ac180-t2@192.0.2.1 lost, then Poll UPS [ac180-t2@192.0.2.1] failed - Data staleThe container is stopped and re-created in steps 6–7None needed: Communications with UPS … established followed 18 s after lost on the first run. If it does not, check running (step 7).
input does not match any value of value-name (/container/get (value-name); line 1) when polling statusRouterOS 7.24.4 has no status property on /containerPoll running instead (step 7).

Field notes#

  • 2026-10-16 21:05 UTC, hAP ax^3, RouterOS 7.24.4 (read-only checks about ten minutes after the third update): the container runs from root-3f4f7db with restart-policy always, and NUT lists all six UPSes. All four tars are on the disk (6.0 to 6.2 MiB each), but /file lists only the current root dir, root-3f4f7db; the earlier root-27e8b5d and root-eb59246 are no longer listed. I have not checked what removes them. Nothing on the disk has been cleaned up; the tars add up with every update. The exports from the second and third runs each contain seven /container envs entries with values, although none was taken with show-sensitive; my update script's comment claimed the opposite (since corrected). The local backups directory is gitignored.
  • 2026-10-16 20:55 UTC, hAP ax^3, RouterOS 7.24.4, Fedora 44, docker-ce 29.9.1, buildx 0.38.0 (update to 3f4f7db): buildx reported the COPY step as CACHED; the tar came out at 6498816 bytes, the same size as for eb59246. Export at 23:55:45 router time, tar on the router at 23:55:46, running: true on the first pass, all six UPSes OL with fresh data by 20:56:08 UTC.
  • 2026-10-16 11:32 UTC, same router and build host (update to eb59246): export at 14:32:46 router time, Script file loaded and executed successfully, running: true on the first pass, all six UPSes OL by 11:33:11 UTC.
  • 2026-10-16 10:23 UTC, same router and build host (update to 27e8b5d, the first versioned update): this run also added one variable to the envlist (step 3); the export taken before it holds three envs entries. One NUT client on the LAN lost contact at 13:23:22 and got it back at 13:23:40 by its own clock (UTC+3, like the router's): 18 s. I measured the gap on this run only. The previous image, nut-observer.tar from the first install, stayed on the disk.

Notes#

  • Whether RouterOS 7.24.4 imports a tar with gzip-compressed layers: not tested. I build uncompressed and check the layer bytes (step 1).
  • MikroTik's Container page lists /container/update, which pulls from a registry, and repull, which re-pulls/extracts. I have not tried them on a file-imported container. That page also still names envlist; 7.24.4 took envlists= in step 5.
  • Why restart-policy matters on this router: A null battery reading cleared the low-battery alarm (first deploy of this container). Other RouterOS container quirks: Six gotchas deep in RouterOS containers.
  • The container in these notes is nut-observer: gitlab.com/random-home-utils/nut-observer.

Filed under: mikrotik, routeros, containers, docker

Sources#

  • MikroTik: Configuration Management — /export parameters; show-sensitive: "By default, sensitive information is hidden." Container env values were in my exports anyway (Field notes).
  • MikroTik: Container — container properties (file, root-dir, envs), update and repull commands; "Adding a Containter will start downloading or extracting it, the Container itself will not be started after it has been added".