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.