Update a RouterOS 7 container from a new image tar
Contents
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-diron 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 asadmin@<ROUTER_IP>. Add your-i <KEY>option to everysshandscpline.<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 yourDockerfileand binary.
- The container exists, and exactly once:Expected:
$ ssh <ROUTER> ':put [:len [/container/find name=observer]]' 11.0means 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#
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>.tarMy build for
eb59246(oneCOPYof a static binary intoFROM 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.tarExpected:
RepoTagscarries<IMAGE>:<VER>andLayerslists your layers. To confirm a layer is stored uncompressed, read its first two bytes. Here on the later3f4f7dbtar:$ tar xOf router/observer/nut-observer.tar blobs/sha256/92a99037ad91a106e2190df5ebe01a9f9d25ed5dc133dec5af0d733712493314 | head -c 2 | od -An -tx1 6e 756e 75isnu, the start of the file namenut-observerin a plain tar header. A gzip stream starts with1f 8b.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.4Expected: a non-empty
.rscwhose first line names your RouterOS version. Store it like a password file: withoutshow-sensitivethe export still contains every/container envsentry 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 thescp, or make sure that directory isn't shared (RouterOS auto-shares new USB disks over SMB; on mine only the media folder is shared).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]]' 1Expected:
1. Skip this step if no variable changes.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:11Expected:
<IMAGE>-<VER>.taris listed with the size of your local tar. This listing is from about ten minutes after the third update: every tar is still there.Write the re-create script
<NAME>-update-<VER>.rsc. Stop, wait, remove, then add the container again from the new tar into a newroot-dir, repeating every other parameter of the original/container/add. Mine foreb59246(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=observerChange
name,file,root-dir,interface,mountlists,envlistsandcmdto yours. Keeprestart-policy=always: on 7.24 the default isno, 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 theaddline picks them up by name. Keep a copy of this file next to your build: it is the next update's rollback.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 successfullyExpected: exactly that line. The service is down from here until step 7 ends.
Start the container once the image is extracted.
/container/addonly starts the extraction; the container has to be started by hand afterwards. MikroTik's page says to wait forstatus=stopped, but 7.24.4 has nostatusproperty, so retrystartand readrunning: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 donerunning: trueExpected:
running: true. In all three runs it came on the first pass. If the loop printsrunning: falseevery 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
3f4f7dbupdate):Expected: flag$ 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 0Randroot-<VER>inROOT-DIR. - The container's log shows the new process starting (needs
logging=yes). Cut from theeb59246run; router clock UTC+3, container clock UTC:Every line printed twice in all three runs. I have not looked into why.$ 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 - 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
addline extracts the tar intoroot-<PREV>again:Then run the step 7 loop.ssh <ROUTER> "/import file-name=<DIR>/<NAME>-update-<PREV>.rsc" - 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/addline 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/addline 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) | Cause | Fix |
|---|---|---|
NUT client logs Communications with UPS ac180-t2@192.0.2.1 lost, then Poll UPS [ac180-t2@192.0.2.1] failed - Data stale | The container is stopped and re-created in steps 6–7 | None 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 status | RouterOS 7.24.4 has no status property on /container | Poll 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-3f4f7dbwithrestart-policyalways, and NUT lists all six UPSes. All four tars are on the disk (6.0 to 6.2 MiB each), but/filelists only the current root dir,root-3f4f7db; the earlierroot-27e8b5dandroot-eb59246are 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 envsentries with values, although none was taken withshow-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 theCOPYstep asCACHED; the tar came out at 6498816 bytes, the same size as foreb59246. Export at 23:55:45 router time, tar on the router at 23:55:46,running: trueon the first pass, all six UPSesOLwith 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: trueon the first pass, all six UPSesOLby 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.tarfrom 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, andrepull, which re-pulls/extracts. I have not tried them on a file-imported container. That page also still namesenvlist; 7.24.4 tookenvlists=in step 5. - Why
restart-policymatters 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 —
/exportparameters;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),updateandrepullcommands; "Adding a Containter will start downloading or extracting it, the Container itself will not be started after it has been added".