Remote upgrade of 16MB-flash boards (hAP lite / RB941-2nD) without Netinstall — a working recipe (+ 3 gotchas)

I've seen a few threads here about hAP lite / RB941-2nD units (16MB total flash) getting stuck with

"not enough disk space" on `/system package update`, and the usual conclusion is "you need Netinstall".

That's still true if the device is truly unreachable remotely, but if you have SSH/Winbox access, there is

a way to do it entirely remotely, no physical access, no BOOTP/Netinstall server needed. Posting the full

recipe since I couldn't find one on the forum that covers the gotchas below — every partial-package thread

I found stopped at "tried uploading individual packages, didn't work, used netinstall" without explaining

why the partial approach failed.

Validated on three production hAP lite units, versions ranging from 6.42.12/6.43.16/6.48.6 → 6.49.20, one

of them reachable only through its own SSTP client (so recovering VPN reachability after reboot was the

real test).

## Why the normal update fails

On these boards, once you're past a few RouterOS releases, the `system` package alone (the core image) is

~5.3MiB compressed. On a 16MiB device with the standard combined install, that alone eats the large

majority of your flash — there just isn't room to hold the currently-installed image *and* stage a new

combined download of everything (`system` + all the packages you never use: `mpls`, `hotspot`, `routing`,

`advanced-tools`, `ipv6`...) at the same time. This is a hardware ceiling, not something wrong with your

config.

## Step 0 — reboot before you do anything else

Before assuming you need Netinstall: just reboot the router with **no changes at all** and check

`/system resource print` → `free-hdd-space` again. On every unit I tried, a plain reboot recovered

several hundred KiB to a few MiB that a live system hadn't reclaimed — RouterOS's flash filesystem doesn't

always finish garbage-collecting deleted files while running; a fresh boot forces compaction. Combine this

with deleting anything reclaimable first (`/file print` — old `autosupout.rif`/`autosupout.old.rif` dumps,

stray `.backup` files, half-finished `.npk` files left over from previous failed update attempts are

common offenders on these boards).

## Step 1 — fetch individual packages directly, from the router itself

Individual `.npk` files are downloadable directly, not only from inside the "Extra Packages" zip:

```

https://download.mikrotik.com/routeros//--.npk

```

e.g. `https://download.mikrotik.com/routeros/6.49.20/system-6.49.20-smips.npk\`. Get `` from

`/system resource print` → `architecture-name`. Run the fetch **on the router**, not on your PC:

```

/tool fetch url="https://download.mikrotik.com/routeros/6.49.20/system-6.49.20-smips.npk" dst-path="system-6.49.20-smips.npk"

```

No controller-side download + SFTP upload round trip needed — the router pulls it directly over HTTPS

(worked fine even on a 2019-build RouterOS with no cert-store issues, in my case).

## The behavior that makes this actually useful (and the trap)

Upload only a *subset* of packages and reboot, and RouterOS does **not** merge them with what's already

installed — it replaces the entire installed package set with exactly what you uploaded. Anything

currently installed that you didn't re-upload is fully removed, not just left at the old version. This is

effectively a mini-Netinstall done via file upload, and it's how you trim unwanted packages and upgrade the

version in the same step.

The trap: this means the very first reboot must already include *every* package the device needs to stay

reachable. There is no "upgrade system now, grab the rest of what I need later" — I tried that first

(upload only `system`, expecting to add the rest afterwards) and it's a dead end: RouterOS does not

auto-fetch anything on its own, ever, so you'd just be left with a box that no longer matches its own

DHCP-client/VPN-client packages until you manually intervene.

**Non-negotiable in the first batch**: `system` (core OS), `security` (SSH/crypto), `dhcp` (if the device

gets its own management address via DHCP client — check `/ip address print` for a `D`-flagged dynamic

address), `ppp` (if reachability depends on a PPP-based VPN client — `l2tp-out`/`sstp-out`/`pptp-out`/

`pppoe-client` in `/interface print`). I now include `ppp` in the first batch even on boxes that don't

obviously need it — it's only ~0.25MiB and being wrong about "not needed" costs a site visit.

Everything else (`wireless`, `advanced-tools`, whatever you actually use) can go in a **second batch**,

fetched by an idempotent script the router runs on its own at the next boot — see below. Never bother with

`/system package disable ` or `/system package uninstall ` to try to trim the combined bundle

first: `disable` doesn't reclaim space for a bundled package (only takes effect for genuinely separate

optional packages), and `uninstall` fails outright with `"failure: can not uninstall bundled package"`.

## Splitting into two reboots when the margin is tight

If your target package set doesn't comfortably fit in current free space in one go, split it: fetch just

the reachability-critical set first (`system`+`security`+`dhcp`+`ppp`, ~6MiB total on 6.49.20/smips) and

reboot, then let a scheduled script fetch the rest and reboot itself. Saved as a `/system script`, triggered

by a `/system scheduler` entry:

```

/system script add name="pkg-autoupgrade" source={

:local version "6.49.20";

:local arch [/system resource get architecture-name];

:local pkglist {"system";"security";"dhcp";"ppp";"wireless";"advanced-tools"};

:local anyFetched false;

:local waited 0;

:while ([:len [/ip route find where dst-address="0.0.0.0/0" active]] = 0 && $waited < 60) do={

:delay 2s;

:set waited ($waited + 2);

}

:log info ("pkg-autoupgrade: network ready after " . $waited . "s");

:foreach pkg in=$pkglist do={

:local found \[/system package find name=$pkg\];

:local needsFetch false;

:if (\[:len $found\] = 0) do={

  :set needsFetch true;

} else={

  :local instver \[/system package get $found version\];

  :if ($instver != $version) do={

    :set needsFetch true;

  }

}

:if ($needsFetch) do={

  :local fname ($pkg . "-" . $version . "-" . $arch . ".npk");

  :local url ("https://download.mikrotik.com/routeros/" . $version . "/" . $fname);

  :do {

    :log info ("pkg-autoupgrade: fetching " . $fname);

    /tool fetch url=$url dst-path=$fname;

    :set anyFetched true;

  } on-error={

    :log error ("pkg-autoupgrade: fetch failed for " . $fname . ", retrying in 5s");

    :delay 5s;

    /tool fetch url=$url dst-path=$fname;

    :set anyFetched true;

  }

}

}

:if ($anyFetched) do={

:log info "pkg-autoupgrade: download complete, rebooting to install";

/system reboot;

} else={

:log info "pkg-autoupgrade: everything already at target version, nothing to do";

}

}

/system scheduler add name="pkg-autoupgrade-onstart" start-time=startup on-event=pkg-autoupgrade

```

## Three gotchas that cost me real debugging time

1. **`:local x {};` (empty array literal) throws a syntax error** inside a script body uploaded via

`/import` — RouterOS's parser seems to get confused by a bare `{}` in this context (possibly ambiguous

with a code block). Use a boolean flag instead of building an array of "packages to fetch", as above.

2. **Check for "package doesn't exist" separately from "package is the wrong version".** After the first

reboot replaces the bundle, anything you *didn't* upload is gone entirely, not just outdated. If your

script does `[/system package get [find name=$pkg] version]` assuming the package still exists (just at

an old version), it throws on a name that no longer exists at all — and the whole script dies silently,

before it logs anything. `[:len [/system package find name=$pkg]] = 0` first, then only call `get` if

something was actually found.

3. **`start-time=startup` fires before the DHCP client necessarily has an address.** Confirmed via

`/log print`: the script's first `/tool fetch` attempt was logged *17 seconds* before

`dhcp-client ... got IP address` appeared in the same log — i.e. it tried to fetch before there was a

route out, failed, and (before I added the wait loop above) died silently. Same story with a PPP VPN

client reconnecting after reboot — expect several `"could not resolve name"` retries in the log before

the tunnel actually comes up; that's the client's own retry logic, unrelated to the script, and it does

recover on its own within a minute or two. Add the `:while` network-readiness wait shown above before

your first `/tool fetch`, and wrap each fetch in `:do {...} on-error={...}` with a retry.

Hope this saves someone else the trial and error.

This isn't a sledge, It looks like a decent guide, but could be easier to read.
Edit it on a computer if you can, it should be easier than a phone.

The double spacing is one issue.
The use of the preformatted text may also help where you have the little code snippets in each paragraph.

I've modified up one section as an example. See what you think, it's your guide.

3. start-time=startup fires before the DHCP client necessarily has an address.

Confirmed via /log print: the script's first /tool fetch attempt was logged *17 seconds* before `dhcp-client ... got IP address` appeared in the same log — i.e. it tried to fetch before there was a route out, failed, and (before I added the wait loop above) died silently.

Same story with a PPP VPN client reconnecting after reboot — expect several "could not resolve name" retries in the log before the tunnel actually comes up. That's the client's own retry logic, unrelated to the script and it does recover on its own within a minute or two. Add the :while network-readiness wait shown above before your first /tool fetch, and wrap each fetch in :do {...} on-error={...} with a retry.