# Update a RouterOS 7 container from a new image tar

> Replace the image of a running RouterOS 7 container with a new docker-save tar, keeping its veth, mounts, envlist and NAT, with a config export first.

Published 2026-11-01. HTML version: https://guides.tsvinc.dev/routeros-container-update-from-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.

> **Scope** — Verified 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:
  ```console
  $ 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.**
   ```bash
   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`):
   ```console
   $ 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:
   ```console
   $ 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.**
   ```bash
   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--`):
   ```console
   $ 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]`
   ```bash
   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:
   ```console
   $ 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.
   ```bash
   scp <IMAGE>.tar "<ROUTER>:/<DIR>/<IMAGE>-<VER>.tar"
   ```
   Upload any files the container reads from its mounts in the same step. Check the disk:
   ```console
   $ 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):
   ```text
   # 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.**
   ```bash
   scp <NAME>-update-<VER>.rsc "<ROUTER>:/<DIR>/"
   ssh <ROUTER> "/import file-name=<DIR>/<NAME>-update-<VER>.rsc"
   ```
   ```console
   $ 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`:
   ```bash
   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
   ```
   ```console
   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):
  ```console
  $ 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:
  ```console
  $ 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:
  ```console
  $ 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:
  ```bash
  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) | 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-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](https://tsvinc.dev/posts/nut-observer-fails-safe/) (first deploy of this container). Other RouterOS container quirks: [Six gotchas deep in RouterOS containers](https://tsvinc.dev/posts/routeros-container-gotchas/).
- The container in these notes is nut-observer: [gitlab.com/random-home-utils/nut-observer](https://gitlab.com/random-home-utils/nut-observer).

## Sources

- [MikroTik: Configuration Management](https://help.mikrotik.com/docs/spaces/ROS/pages/328155/Configuration+Management) — `/export` parameters; `show-sensitive`: "By default, sensitive information is hidden." Container env values were in my exports anyway (Field notes).
- [MikroTik: Container](https://help.mikrotik.com/docs/spaces/ROS/pages/84901929/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".

