Skip to content

Instantly share code, notes, and snippets.

@marfillaster
Last active May 26, 2026 11:10
Show Gist options
  • Select an option

  • Save marfillaster/9fa1a9571d4d93b2992977d33d99b1a9 to your computer and use it in GitHub Desktop.

Select an option

Save marfillaster/9fa1a9571d4d93b2992977d33d99b1a9 to your computer and use it in GitHub Desktop.
UniFi Network Application + standalone MongoDB as RouterOS containers on a MikroTik RB5009 (1 GiB RAM, ARMv8.0-A Cortex-A72), with USB partitioned into swap + ext4

UniFi Network Application on MikroTik RB5009

Self-hosted UniFi Network controller and MongoDB running as two RouterOS containers on a MikroTik RB5009UPr+S+, backed by a USB stick split into swap and ext4 data partitions.

Part of a larger home-network build — full write-up with topology, VLANs, IPv6-over-WireGuard, and rationale: https://blog.homestack.space/mikrotik-home-network

Hardware

  • Router: MikroTik RB5009UPr+S+
    • CPU: Marvell Armada A72 (Cortex-A72), ARMv8.0-A, 4 cores @ 1.4 GHz
    • RAM: 1024 MiB
    • RouterOS: 7.22.3 (stable)
  • Storage: Samsung Flash Drive FIT, 64 GB, USB 3.10 (5 Gbps), slot usb1

Final layout

Resource Value
usb1-part1 8 GiB raw partition, kernel swap (swap=yes)
usb1-part2 ~55 GiB ext4, mounted at /usb1-part2
Container layer dir /usb1-part2/images
Container tmp dir /usb1-part2/tmp
MongoDB container mongo on veth1-mongo, IP 192.168.88.2/24
UniFi container unifi on veth2-unifi, IP 192.168.88.3/24
UniFi GUI https://unifi.home.arpa/ (port 443, mkcert cert)
Mongo TCP 192.168.88.2:27017

After both containers are up, free RAM sits around 250 MiB. Burst pressure spills to the USB swap.

Why these specific images

  • MongoDB: arm64v8/mongo:4.4.18mongo:5.0+ and even current mongo:4.4 (4.4.19+) ARM64 builds require ARMv8.2-A atomics; the RB5009's Cortex-A72 is ARMv8.0-A and the binary dies with SIGILL (container exit status 132). 4.4.18 is the last tag whose binary still runs. UniFi Network Application 9.x supports MongoDB 3.6–7.0, so 4.4 is in range.
  • UniFi: lscr.io/linuxserver/unifi-network-application:latest — LinuxServer image, which expects an external Mongo (perfect for this split).

Resource sizing decisions

  • 8 GiB swap. UniFi is Java (default 1 GB heap) and Mongo wants RAM for WiredTiger; with 1 GiB physical RAM you need real swap. The heap is capped to 384 MiB and Wired­Tiger cache to 256 MiB, leaving headroom — swap is the safety net.
  • MEM_LIMIT=384, MEM_STARTUP=256 for the UniFi JVM.
  • --wiredTigerCacheSizeGB 0.25 for Mongo.
  • In-container tmpfs: UniFi /tmp = 128 MiB, Mongo /tmp = 64 MiB. Configured via the tmpfs field on /container (see syntax notes below).

Networking

Both veths sit on the existing main bridge as untagged members of VLAN 1, the same VLAN that already serves the 192.168.88.0/24 LAN. From a Layer-2 perspective the containers look like normal LAN clients.

RouterOS                                Containers
  bridge (192.168.88.1/24, vlan 1)
   ├── ether2/3 (LAN, untagged VLAN 1)
   ├── veth1-mongo ─────────────── eth0: 192.168.88.2/24 → 192.168.88.1
   └── veth2-unifi ─────────────── eth0: 192.168.88.3/24 → 192.168.88.1

The address= and gateway= on each /interface/veth entry are pushed into the container's eth0. The host side of the veth pair is unaddressed; it's just a bridge port.

End-to-end reproduction

All steps are run from a workstation that already has key-based SSH access to admin@192.168.88.1.

The whole sequence is destructive to the USB partition usb1-part1 (its FAT32 contents are wiped). Verify the stick is empty first with /file/print where name~"usb1-part1".

1. Stop using the existing FAT32 mount

/container/config/set layer-dir="" tmpdir=""

2. Repartition usb1 to swap + ext4

:foreach p in=[/disk/find slot~"usb1-part"] do={/disk/remove $p}
/disk/add parent=usb1 type=partition partition-size=8589934592   ;# 8 GiB swap
/disk/add parent=usb1 type=partition                              ;# rest = ext4
/disk/format numbers=usb1-part2 file-system=ext4

RouterOS quirks worth knowing here:

  • /disk/format-drive does not exist — the verb is /disk/format.
  • /disk/format <slot> file-system=ext4 only formats one partition; it does not accept a partition layout. Multi-partition setups must be built with /disk/add type=partition.
  • /disk/add type=partition accepts parent=<diskSlot> and partition-size=<bytes> (no GiB suffix; pass raw bytes). Omitting partition-size consumes the remaining free space.
  • /disk/add type=partition does not accept file-system= — the partition is created raw and you call /disk/format afterwards (or, for swap, leave it raw and just set swap=yes).

3. Enable swap on usb1-part1

/disk/set [find slot="usb1-part1"] smb-sharing=no media-sharing=no
/disk/set [find slot="usb1-part1"] swap=yes

swap=yes is mutually exclusive with smb-sharing and media-sharing, so those have to be turned off first. The partition does not need a linux-swap filesystem signature — RouterOS treats a raw partition flagged swap=yes as kernel swap directly.

4. Create the data directories on ext4

/file/add type=directory name="usb1-part2/images"
/file/add type=directory name="usb1-part2/tmp"
/file/add type=directory name="usb1-part2/unifi-config"
/file/add type=directory name="usb1-part2/mongo-data"
/file/add type=directory name="usb1-part2/mongo-config"
/file/add type=directory name="usb1-part2/mongo-initdb"

5. Point container storage at ext4

/container/config/set layer-dir=/usb1-part2/images tmpdir=/usb1-part2/tmp

6. Create veth interfaces and join the bridge

/interface/veth/add name=veth1-mongo  address=192.168.88.2/24 gateway=192.168.88.1 gateway6=""
/interface/veth/add name=veth2-unifi  address=192.168.88.3/24 gateway=192.168.88.1 gateway6=""

/interface/bridge/port/add bridge=bridge interface=veth1-mongo pvid=1
/interface/bridge/port/add bridge=bridge interface=veth2-unifi pvid=1

# Add veths to VLAN 1's untagged members (vlan-filtering bridge requires this)
/interface/bridge/vlan/set [find vlan-ids=1] \
    untagged=bridge,ether2,ether3,veth1-mongo,veth2-unifi

If the bridge has vlan-filtering=no, the last step isn't required — but on a defconf RB5009 it's on by default.

7. Create the Mongo bootstrap init.js

LinuxServer's UniFi image expects three databases: unifi, unifi_stat, and unifi_audit (the third one was added in newer UniFi versions and is the easiest thing to miss).

// /usb1-part2/mongo-initdb/init-mongo.js
db.getSiblingDB("unifi").createUser({
  user: "unifi",
  pwd: "<MONGO_UNIFI_PASS>",
  roles: [
    { role: "dbOwner",              db: "unifi" },
    { role: "dbOwner",              db: "unifi_stat" },
    { role: "dbOwner",              db: "unifi_audit" },
    { role: "readWriteAnyDatabase", db: "admin" },
    { role: "dbAdminAnyDatabase",   db: "admin" }
  ]
});

The *AnyDatabase roles on admin are required for backup restore — UniFi creates a transient unifi_restore database during import, then drops it. Without those roles the restore fails silently in the GUI with code 13 Unauthorized on unifi_restore in server.log.

Upload it:

scp init-mongo.js admin@192.168.88.1:usb1-part2/mongo-initdb/init-mongo.js

The official mongo image runs every *.js and *.sh in /docker-entrypoint-initdb.d/ exactly once, on the first start of an empty data directory.

8. Mongo envs and mounts

/container/envs/add list=mongo-envs key=MONGO_INITDB_ROOT_USERNAME value=root
/container/envs/add list=mongo-envs key=MONGO_INITDB_ROOT_PASSWORD value=<MONGO_ROOT_PASS>

/container/mounts/add list=mongo-mounts src=/usb1-part2/mongo-data    dst=/data/db
/container/mounts/add list=mongo-mounts src=/usb1-part2/mongo-config  dst=/data/configdb
/container/mounts/add list=mongo-mounts src=/usb1-part2/mongo-initdb  dst=/docker-entrypoint-initdb.d read-only=yes

RouterOS /container/mounts/add uses list= (not name=) to group mounts; the container then references that list via mountlists=. There's no name= field on a mount.

9. Pull and configure the Mongo container

/container/add remote-image=arm64v8/mongo:4.4.18 interface=veth1-mongo envlist=mongo-envs

# Wait until /container/print shows the image is no longer in DOWNLOADING/EXTRACTING, then:
/container/set [find name="mongo:4.4.18"] \
    mountlists=mongo-mounts \
    hostname=mongo \
    name=mongo \
    start-on-boot=yes \
    logging=yes \
    cmd="mongod --wiredTigerCacheSizeGB 0.25 --bind_ip_all --ipv6" \
    tmpfs="/tmp:64M:fixed"

Important RouterOS container-add quirks:

  • The param is envlist= (singular on input; stored as envlists plural).
  • The mounts param is mountlists= (not mounts=), and it can only be applied via /container/set after the image has finished downloading — you cannot pass it in the initial /container/add line.
  • tmpfs= is a colon-separated record path:size:mode. Mode defaults to 01777 (standard /tmp sticky-bit). The original misleading error expected value of size-mode actually means "you didn't tell me the third (mode) field of the tmpfs spec."

10. UniFi envs

/container/envs/add list=unifi-envs key=PUID              value=1000
/container/envs/add list=unifi-envs key=PGID              value=1000
/container/envs/add list=unifi-envs key=TZ                value=Asia/Manila
/container/envs/add list=unifi-envs key=MONGO_HOST        value=192.168.88.2
/container/envs/add list=unifi-envs key=MONGO_PORT        value=27017
/container/envs/add list=unifi-envs key=MONGO_USER        value=unifi
/container/envs/add list=unifi-envs key=MONGO_PASS        value=<MONGO_UNIFI_PASS>
/container/envs/add list=unifi-envs key=MONGO_DBNAME      value=unifi
/container/envs/add list=unifi-envs key=MONGO_AUTHSOURCE  value=unifi
/container/envs/add list=unifi-envs key=MEM_LIMIT         value=384
/container/envs/add list=unifi-envs key=MEM_STARTUP       value=256

11. UniFi mounts and container

/container/mounts/add list=unifi-mounts src=/usb1-part2/unifi-config dst=/config

/container/add remote-image=lscr.io/linuxserver/unifi-network-application:latest \
    interface=veth2-unifi envlist=unifi-envs

# After the pull finishes:
/container/set [find name="unifi-network-application:latest"] \
    mountlists=unifi-mounts \
    hostname=unifi \
    name=unifi \
    start-on-boot=yes \
    logging=yes \
    dns=192.168.88.1 \
    tmpfs="/tmp:128M:fixed"

The dns= field is critical for UniFi. Without it the container has no resolv.conf entries, so sso.ui.com doesn't resolve and the GUI surfaces "There was a problem logging into your account" when you sign in with a Ubiquiti account. The router itself runs a resolver (/ip/dns allow-remote-requests=yes on defconf), so pointing at 192.168.88.1 is enough. Set the same on the Mongo container for symmetry, though Mongo doesn't actually need it.

12. Start, in order

/container/start [find name="mongo"]
# Watch /log/print where topics~"container" until Mongo finishes init scripts
/container/start [find name="unifi"]

Then open https://192.168.88.3:8443 and run the controller setup wizard.

Verifying

# Mongo TCP reachable from LAN
nc -z -w 2 192.168.88.2 27017 && echo OK

# UniFi GUI reachable
curl -sk https://192.168.88.3:8443/manage/account/login \
    -o /dev/null -w "HTTP %{http_code}\n"   # expect 200

From a host with pymongo installed:

from pymongo import MongoClient
c = MongoClient("mongodb://unifi:<MONGO_UNIFI_PASS>@192.168.88.2:27017/unifi?authSource=unifi")
c.unifi.command("ping")           # {'ok': 1.0}

Container stdout (with logging=yes) appears in RouterOS log:

/log/print where topics~"container"

Swap activation can be confirmed with the S flag in:

/disk/print where slot~"usb1-part"

Known sharp edges

  1. MongoDB ARM CPU requirement. Anything newer than mongo:4.4.18 (for the ARM64 manifest) requires ARMv8.2-A and SIGILLs on the RB5009. Treat the working tag as pinned. If you need a newer/supported MongoDB, build it from source for ARMv8.0-A — cross-build on a Mac with docker buildx build --platform linux/arm64/v8 and push to a local registry the router can pull from, not on the router itself.

  2. size= suffix on partition-size. /disk/add … partition-size=8GiB fails ("invalid trailing characters"). Pass raw bytes: 8589934592.

  3. /container/mounts uses list= not name=. A mount entry has fields list, src, dst, read-only. There is no name=.

  4. mounts=mountlists=. On /container/add and /container/set, the parameter is mountlists=. Same for envlist/envlists.

  5. You can't set mountlists= during the initial /container/add. RouterOS rejects it ("expected end of command"). Add the container with the image and interface first, then /container/set ... mountlists= once the image has finished extracting.

  6. tmpfs value syntax. <dst>:<size>:<mode>. The mode is octal (e.g. 01777). RouterOS will accept the string fixed for the mode and normalise it to 01777. Without a third field the parser errors out as "expected value of size-mode."

  7. unifi_audit database. It exists alongside unifi and unifi_stat and the Mongo user must have dbOwner on it, or the controller fails at startup with code 13 Unauthorized on unifi_audit. The init.js above already covers this. 7a. Backup restore needs *AnyDatabase roles. Restore creates a transient unifi_restore database and drops it after merging. With only the per-database dbOwner grants, the restore aborts (the GUI just looks stuck; the real error not authorized on unifi_restore lands in /usr/lib/unifi/logs/server.log). The init.js grants readWriteAnyDatabase + dbAdminAnyDatabase on admin to fix it. To grant on a live install:

    c.unifi.command('grantRolesToUser', 'unifi', roles=[
        {'role': 'readWriteAnyDatabase', 'db': 'admin'},
        {'role': 'dbAdminAnyDatabase',   'db': 'admin'},
    ])

    Note: grantRolesToUser must be run against the user's auth database (unifi), not admin, even when granting roles defined on admin.

  8. Container DNS is not inherited from the host. A new /container entry has dns= empty, which means no resolv.conf and no name resolution from inside the container — the LAN-side networking works (because the gateway is an IP, not a name) but anything that calls outbound by hostname dies. UniFi's Ubiquiti SSO login is the most visible victim ("There was a problem logging into your account"). Set dns=192.168.88.1 (or any reachable resolver) on each container.

  9. Outbound NAT just works as long as the bridge VLAN that the veth lives on is already covered by /ip/firewall/nat action=masquerade out-interface-list=WAN — the default defconf masquerade rule applies to the container the same way it applies to any 192.168.88.0/24 client.

  10. IPv6 is SLAAC-only. /interface/veth has gateway6= but no address6= field — there's no way to set a static IPv6 address. As long as the bridge that the veth joins is sending RAs (the defconf /ipv6/nd entry for bridge does this), the container's veth interface autoconfigures LLA + ULA + GUA inside its netns the moment it comes up. No further config needed. To verify from inside the container (without iproute2): cat /proc/net/if_inet6 — entries 2/3/4 should be the LLA/ULA/GUA bound to veth1-mongo/veth2-unifi (note: the interface name inside the container is the veth name, not eth0).

  11. MongoDB needs --ipv6 in addition to --bind_ip_all. In 4.x, --bind_ip_all only enables the v4 wildcard; without the explicit --ipv6 flag, mongod ignores v6 even when an IPv6 address is configured on its NIC. UniFi 8443 over v6 works without any tweaks because Tomcat binds dual-stack by default.

  12. serverStatus 13/Unauthorized in the Mongo log is benign. UniFi probes admin.serverStatus; the unifi user can't run it; the controller doesn't care.

  13. Mongo 4.4.18 is EOL. It's the highest version that boots on this CPU, but it no longer gets security updates. Restrict Mongo to the LAN (it already is — TCP 27017 is only on 192.168.88.0/24) and don't add a WAN port-forward to it.

Friendly hostname, mkcert TLS, port 443

For https://unifi.home.arpa/ (no port, valid-looking padlock) you need three pieces:

1. Local DNS on the router. home.arpa is the RFC 8375 reserved TLD for residential networks; resolvers should answer it locally without forwarding upstream.

/ip/dns/static/add name=unifi.home.arpa address=192.168.88.3 type=A
/ip/dns/static/add name=unifi.home.arpa address=fd96:7d0b:7dc2:1:685f:a2ff:fe5f:212c type=AAAA

Use the ULA for the AAAA, not the GUA — your ISP may rotate the GUA prefix at lease renewal; the ULA is stable as long as the veth's container-mac-address doesn't change.

2. mkcert root CA + leaf cert. Let's Encrypt won't issue for home.arpa (no public DNS, no way to validate ownership). The pragmatic substitute is a local CA that you trust on each device.

brew install mkcert nss
mkcert -install                                       # installs CA in macOS keychain + NSS (Firefox)
mkcert -cert-file unifi.crt -key-file unifi.key \
    unifi.home.arpa 192.168.88.3 \
    fd96:7d0b:7dc2:1:685f:a2ff:fe5f:212c

The CA root at $(mkcert -CAROOT)/rootCA.pem is what other devices need:

  • iOS: AirDrop → Settings → General → VPN & Device Management → install → then Settings → General → About → Certificate Trust Settings → enable full trust for the mkcert CA.
  • Android: Settings → Security → Encryption & credentials → Install a certificate → CA certificate.
  • Windows: double-click rootCA.pem → Install Certificate → Local Machine → Trusted Root Certification Authorities.

3. Inject the cert into UniFi's keystore + bind port 443. The newer LinuxServer image (unifi-network-application) does NOT have the older image's /config/cert.pem auto-import; it just generates a self-signed keystore once at /config/data/keystore. To use a custom cert, overwrite that file directly.

openssl pkcs12 -export \
    -in unifi.crt -inkey unifi.key \
    -name unifi -out keystore \
    -password pass:aircontrolenterprise

# Stop UniFi (so it doesn't hold the file)
ssh admin@192.168.88.1 '/container/stop [find name="unifi"]; :delay 8s'

scp keystore admin@192.168.88.1:usb1-part2/unifi-config/data/keystore

The keystore alias must be unifi and the password must be aircontrolenterprise — those values are hard-coded in UniFi.

Then bind port 443 instead of 8443:

# Add unifi.https.port=443 to /usb1-part2/unifi-config/data/system.properties
# (a single new line at end of file is fine)

…and switch the container to run as root so Java can bind a privileged port. /container does not expose a cap_add field, so there is no way to grant CAP_NET_BIND_SERVICE to the LinuxServer image's default abc user (UID 1000). The simplest workaround is PUID=0 PGID=0, which makes the linuxserver init set abc's UID to 0:

/container/envs/set [find list=unifi-envs key=PUID] value=0
/container/envs/set [find list=unifi-envs key=PGID] value=0
/container/start [find name="unifi"]

Verify:

curl -k --resolve unifi.home.arpa:443:192.168.88.3 https://unifi.home.arpa/ -o /dev/null -w "%{http_code}\n"   # 200
echo | openssl s_client -connect 192.168.88.3:443 -servername unifi.home.arpa 2>/dev/null \
    | openssl x509 -noout -subject -issuer -ext subjectAltName
# subject=O=mkcert development certificate ...
# issuer =O=mkcert development CA, ...
# SANs: DNS:unifi.home.arpa, IP:192.168.88.3, IP:fd96:...:212c

Why this trade-off

Cleaner alternatives exist but each has a cost:

Path Cost
Reverse-proxy container (Caddy) on :443 +30 MiB RAM; we're already at ~160 MiB free. Cleanest separation, but the budget is tight.
Real LE cert Requires you own a public domain, and DNS-01 via your DNS provider's API. Not currently the case.
Java with file-cap cap_net_bind_service=+ep on the java binary Requires custom image or a one-shot setcap in the entrypoint; LinuxServer image doesn't ship this.
dst-nat on the router Doesn't help — LAN→container traffic is forwarded L2 by the bridge, never hits the router's IP-layer NAT.

PUID=0/PGID=0 is ugly (UniFi now writes /config as root and the JVM has more privilege than it needs) but it's the lowest-friction option on a 1 GiB router. If you later move UniFi to a host with capability-add support, drop both env vars and put CAP_NET_BIND_SERVICE on the JVM instead.

Files in this directory

  • README.md — this writeup.

Secrets (MONGO_ROOT_PASS, MONGO_UNIFI_PASS) are not committed; they were generated with openssl rand -base64 24 and stored only on the deploying workstation.

@klausbfrederiksen

Copy link
Copy Markdown

Don't you need to get the container?
container/add remote-image=linuxserver/unifi-controller:latest interface=veth1 root-dir=disk1/unifi

@marfillaster

Copy link
Copy Markdown
Author

@klausbfrederiksen good catch! Hilarious I missed the most important part lol

@markwell-ch

Copy link
Copy Markdown

@marfillaster is this setup running stable for you? No reboots due to out of memory conditions? (I'm using RouterOS 7.8)

@marfillaster

Copy link
Copy Markdown
Author

I stopped experimenting on this. But yeah it always get OOM'ed and the boot time is atrocious.

@markwell-ch

Copy link
Copy Markdown

Wow, thanks for the fast reply 🚀 Sad that it's not running stable on RB5009.

@mriscoc

mriscoc commented May 10, 2026

Copy link
Copy Markdown

So, it's not worth the effort to try to run the Unifi controller in a Docker inside the RB5009? That's a shame.

@marfillaster

Copy link
Copy Markdown
Author

@mriscoc revisited this. It's viable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment