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
- 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
| 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.
- MongoDB:
arm64v8/mongo:4.4.18—mongo:5.0+and even currentmongo: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 withSIGILL(container exit status 132).4.4.18is 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).
- 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 WiredTiger cache to 256 MiB, leaving headroom — swap is the safety net.
MEM_LIMIT=384,MEM_STARTUP=256for the UniFi JVM.--wiredTigerCacheSizeGB 0.25for Mongo.- In-container tmpfs: UniFi
/tmp= 128 MiB, Mongo/tmp= 64 MiB. Configured via thetmpfsfield on/container(see syntax notes below).
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.
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".
/container/config/set layer-dir="" tmpdir="":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=ext4RouterOS quirks worth knowing here:
/disk/format-drivedoes not exist — the verb is/disk/format./disk/format <slot> file-system=ext4only formats one partition; it does not accept a partition layout. Multi-partition setups must be built with/disk/add type=partition./disk/add type=partitionacceptsparent=<diskSlot>andpartition-size=<bytes>(noGiBsuffix; pass raw bytes). Omittingpartition-sizeconsumes the remaining free space./disk/add type=partitiondoes not acceptfile-system=— the partition is created raw and you call/disk/formatafterwards (or, for swap, leave it raw and just setswap=yes).
/disk/set [find slot="usb1-part1"] smb-sharing=no media-sharing=no
/disk/set [find slot="usb1-part1"] swap=yesswap=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.
/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"/container/config/set layer-dir=/usb1-part2/images tmpdir=/usb1-part2/tmp/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-unifiIf the bridge has vlan-filtering=no, the last step isn't required — but on a defconf RB5009 it's on by default.
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.jsThe official mongo image runs every *.js and *.sh in /docker-entrypoint-initdb.d/ exactly once, on the first start of an empty data directory.
/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=yesRouterOS
/container/mounts/adduseslist=(notname=) to group mounts; the container then references that list viamountlists=. There's noname=field on a mount.
/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 asenvlistsplural). - The mounts param is
mountlists=(notmounts=), and it can only be applied via/container/setafter the image has finished downloading — you cannot pass it in the initial/container/addline. tmpfs=is a colon-separated recordpath:size:mode. Mode defaults to01777(standard/tmpsticky-bit). The original misleading errorexpected value of size-modeactually means "you didn't tell me the third (mode) field of the tmpfs spec."
/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/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 noresolv.confentries, sosso.ui.comdoesn'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=yesondefconf), so pointing at192.168.88.1is enough. Set the same on the Mongo container for symmetry, though Mongo doesn't actually need it.
/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.
# 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 200From 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"-
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 withdocker buildx build --platform linux/arm64/v8and push to a local registry the router can pull from, not on the router itself. -
size=suffix onpartition-size./disk/add … partition-size=8GiBfails ("invalid trailing characters"). Pass raw bytes:8589934592. -
/container/mountsuseslist=notname=. A mount entry has fieldslist, src, dst, read-only. There is noname=. -
mounts=≠mountlists=. On/container/addand/container/set, the parameter ismountlists=. Same forenvlist/envlists. -
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. -
tmpfsvalue syntax.<dst>:<size>:<mode>. Themodeis octal (e.g.01777). RouterOS will accept the stringfixedfor the mode and normalise it to01777. Without a third field the parser errors out as "expected value of size-mode." -
unifi_auditdatabase. It exists alongsideunifiandunifi_statand the Mongo user must havedbOwneron it, or the controller fails at startup withcode 13 Unauthorized on unifi_audit. The init.js above already covers this. 7a. Backup restore needs*AnyDatabaseroles. Restore creates a transientunifi_restoredatabase and drops it after merging. With only the per-databasedbOwnergrants, the restore aborts (the GUI just looks stuck; the real errornot authorized on unifi_restorelands in/usr/lib/unifi/logs/server.log). The init.js grantsreadWriteAnyDatabase+dbAdminAnyDatabaseonadminto fix it. To grant on a live install:c.unifi.command('grantRolesToUser', 'unifi', roles=[ {'role': 'readWriteAnyDatabase', 'db': 'admin'}, {'role': 'dbAdminAnyDatabase', 'db': 'admin'}, ])
Note:
grantRolesToUsermust be run against the user's auth database (unifi), notadmin, even when granting roles defined onadmin. -
Container DNS is not inherited from the host. A new
/containerentry hasdns=empty, which means noresolv.confand 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"). Setdns=192.168.88.1(or any reachable resolver) on each container. -
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 defaultdefconfmasquerade rule applies to the container the same way it applies to any 192.168.88.0/24 client. -
IPv6 is SLAAC-only.
/interface/vethhasgateway6=but noaddress6=field — there's no way to set a static IPv6 address. As long as the bridge that the veth joins is sending RAs (thedefconf/ipv6/ndentry forbridgedoes 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 (withoutiproute2):cat /proc/net/if_inet6— entries 2/3/4 should be the LLA/ULA/GUA bound toveth1-mongo/veth2-unifi(note: the interface name inside the container is the veth name, noteth0). -
MongoDB needs
--ipv6in addition to--bind_ip_all. In 4.x,--bind_ip_allonly enables the v4 wildcard; without the explicit--ipv6flag, 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. -
serverStatus13/Unauthorized in the Mongo log is benign. UniFi probesadmin.serverStatus; theunifiuser can't run it; the controller doesn't care. -
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.
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=AAAAUse 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:212cThe 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/keystoreThe 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:...:212cCleaner 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.
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.
Don't you need to get the container?
container/add remote-image=linuxserver/unifi-controller:latest interface=veth1 root-dir=disk1/unifi