Version-pinned, multi-arch (amd64 + arm64) OCI images of UniFi controllers, built as test targets for automation: deterministic startup, a health signal that means "the controller API is answering", no setup wizard in simulation mode, testcontainers-friendly.
Not production hosting. Runs anywhere standard OCI images run — Docker, Podman, Colima, Kubernetes.
| Image | Product |
|---|---|
ghcr.io/jamesbraid/unifi-network |
UniFi Network Application (standalone) |
ghcr.io/jamesbraid/unifi-os-server |
UniFi OS Server |
| Image | Version | Release notes |
|---|---|---|
ghcr.io/jamesbraid/unifi-network |
10.6.106 | Release notes |
ghcr.io/jamesbraid/unifi-os-server |
5.1.42 | Release notes |
The UOS images carry the current Network application, not the one the
installer happened to bundle: the build runs the product's own updater
(uos runnable install unifi) against the pinned release — the same OTA a
real console performs. The bundled version is on every UOS image as the OCI
label org.unifi-containers.network-version, and at runtime in
stat/sysinfo.
| Variant | Image tags |
|---|---|
| Base | X.Y.Z, latest |
| Simulation | X.Y.Z-sim, sim |
| Seeded | X.Y.Z-seeded, seeded |
X.Y.Z is the upstream version. Build numbers are a git concept and never
appear as an image tag: X.Y.Z-sim is the current build of that upstream
version, and it moves when we rebuild the image without an upstream change — a
Dockerfile or healthcheck fix. latest / sim / seeded follow the highest
stable upstream version, not the most recent release, so rebuilding an older
version does not drag them backwards.
For something that never moves, pin the digest (image@sha256:…). Every
other name slides. docker inspect reports what you actually have in
org.opencontainers.image.version and which commit built it in
org.opencontainers.image.revision.
The consequence of everything sliding: a superseded build ends up untagged and becomes garbage-collectable, so rolling back means cutting a new build rather than repointing at an old one.
Git tags are product-scoped and carry the build number:
network/10.4.57-1, unifi-os/5.1.21-3.
Only GA upstream versions are built. Ubiquiti publishes release candidates too, and the community feed cannot tell you which is which — it carries no channel field at all, so a candidate and a GA release appear identically. The channel is stated in the firmware API, which is what the updater asks.
That API publishes the same application under two product ids, and the difference matters:
| product | artifact | says GA is |
|---|---|---|
unifi |
the app bundled into UniFi OS | current, and complete |
unifi-controller |
the standalone .deb these images install |
days to weeks behind, and skips versions |
So the version comes from unifi and the checksum from
unifi-controller whenever it has caught up to that version — otherwise the
.deb is hashed. Reading the version from the .deb product instead would park
the pin on a superseded release with nothing to say so: it has no 10.2.x record
at all, though 10.2.105 was GA.
The version comes from the pins, the build number from the tags. So a bump is automatic — the updater rewrites the pin, and CI notices the pinned version has no tag yet and cuts build 1. A rebuild is deliberate:
unifi-containers cut-release --all # what is due; no --push, no tag
unifi-containers cut-release network --rebuild --push # next build of the pinned versionPushing the tag is the whole release trigger: the mirror to GitHub syncs on push, so a tag reaching the canonical repo reaches the image build by itself. Nothing in this repo asks anything to synchronise.
Tags from before this scheme are archived under legacy/ and still
checkout-able. They were not renamed because they could not be: v10.4.57 and
v10.4.57-1 are different commits and were different published releases, so
both would have mapped onto network/10.4.57-1.
The -sim tags boot straight into a demo controller: admin/admin
account, seeded demo sites and devices (3 APs, 1 gateway, 5 switches), no
setup wizard. The image's healthcheck reports healthy only once a real JSON
login succeeds, the v2 API surface answers, and all 9 demo devices are
present — the three things that come up at different times during boot. So
"wait for healthy" is the whole readiness contract:
docker run -d --name unifi -p 8443:8443 ghcr.io/jamesbraid/unifi-network:sim
# wait for healthy, then:
curl -ks -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}' https://localhost:8443/api/loginor TAG=sim docker compose -f network/examples/docker-compose.yml up --wait.
Simulation fidelity (which writes stick, how stats evolve) is a per-version
empirical question — treat it as a controller-API test double, not a
device-network emulator. unifi-containers sim-keys <version> prints the
full simulation/demo key set a release ships, which doubles as the de facto
documentation for it (notably demo.username / demo.password /
demo.skip_wizard / demo.*_model).
Two things about the demo fleet worth knowing before you write assertions
against it. Demo device models randomize per boot and some drawn models
are un-adoptable (e.g. legacy BZ2LR), so a fleet is not deterministic
across restarts — pin models via the demo.*_model keys if a test needs
stable hardware. And demo devices arrive pending-adoption (no _id)
until a test adopts them; the pool does not refill, so treat sim containers
as per-session disposable rather than reusing one across many
adopt-heavy runs.
The -seeded tags carry a controller whose first-run setup is already
completed, so the container boots straight to a working login — no wizard,
no demo devices, real empty site. The two products seed different API
surfaces (see below), so their credentials differ.
unifi-network:seeded — the Network App wizard is completed at image
build time. Credentials: admin / unifi-containers-seeded
docker run -d --name unifi -p 8443:8443 ghcr.io/jamesbraid/unifi-network:seeded
# wait for healthy (healthcheck = seeded-login + v2 probes), then the API is yours:
curl -ks -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"unifi-containers-seeded"}' \
https://localhost:8443/api/loginunifi-os-server:seeded — the UOS first-run setup is completed
headlessly at first boot via unifi-core's own /api/setup (no UI account,
no cloud/SSO), giving an Owner admin on the UOS-native API (:443).
This is the future-proof surface: it works regardless of the bundled
Network App. It seeds no demo devices — for fake devices on the Network App
API use -sim (the two setup paths are mutually exclusive; see below).
Credentials: admin / admin (override with UOS_ADMIN_USER /
UOS_ADMIN_PASS; also UOS_COUNTRY, UOS_TIMEZONE).
TAG=seeded docker compose -f unifi-os/examples/docker-compose.yml up --wait
# healthcheck = API-key probe + ucore-login probe + v2 probe; then:
curl -ks -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}' \
https://localhost:11443/api/auth/loginThe same boot mints an X-API-KEY and writes it to /unifi/api-key.
Production UniFi OS clients authenticate with that header rather than a
cookie, so a harness holding this key drives the real /proxy/network
dialect and skips SSO entirely.
Read the key whichever way suits the harness:
key=$(docker exec <container> cat /unifi/api-key) # from the container
key=$(cat /path/to/volume/api-key) # from a mounted volume
curl -ks -H "X-API-KEY: $key" \
https://localhost:11443/proxy/network/api/s/default/stat/deviceThe seed records the key in /unifi/logs/uos-seed-owner.log and in docker logs,
either of which is a second way to fetch it and the first place to look when a
boot goes wrong. That is deliberate: this is a random key on an admin/admin
test target, so it is not a secret worth hiding. Do not treat these images as
somewhere to keep one.
The path is the contract; the value is not. UniFi OS mints the key itself and
ignores any value a caller supplies, so expect 32 random characters, fresh for
each volume, carrying full admin scope and no expiry. Restarts reuse the same
key. Set UOS_API_KEY_FILE or UOS_API_KEY_NAME to move or rename it, and
UOS_SEED_API_KEY=false to skip minting.
What the key opens:
| Endpoint | With X-API-KEY |
|---|---|
/proxy/network/api/s/<site>/... (classic dialect) |
works |
/proxy/network/integration/v1/... (integration API) |
works |
/api/... (ucore, e.g. /api/users/self) |
401 — these need a session |
The console's identity service owns API keys, so mint further ones there:
POST /api/v2/user/<owner-uuid>/keys on 127.0.0.1:9080 inside the
container, taking the owner UUID from GET /api/v2/info. unifi-core has no
API-key route of its own. The -sim variant seeds no key, having no UOS
owner.
Every image's healthcheck proves the surfaces a harness actually uses are
live, not merely that the port is open: a real JSON login, the v2 API (which
5xxs for a window after login works, while zone-based-firewall defaults
materialize), and on -sim the full demo fleet. Waiting for healthy is
therefore sufficient — you should not need a readiness poll of your own:
- docker compose:
docker compose up --wait - testcontainers (any language): use the container-healthy wait
strategy, e.g. Go:
wait.ForHealthCheck(); Java:Wait.forHealthcheck(). Container reuse across tests works well with the sim/seeded variants since state is deterministic. - Anything holding only a URL:
GET :9099/readyz— 200 once ready, 503 until then. Docker will not serve its health verdict over the network, so a caller that did not start the container has no way to read it. This does. It runs the same probe the healthcheck runs, so it is the same verdict, not a second opinion. - Startup budgets, measured to healthy on Apple Silicon / native arm64:
unifi-network:sim39 s;unifi-os-serverbase 32 s,-sim66 s,-seeded78 s. Healthy now also waits for the v2 surface and the demo fleet, so on a loaded machine it can land well past these — that wait used to happen in your test code instead. Size your own timeout off the CI gate's ceilings instead — 300 s for the network images, 900 s for UOS — because a cold pull and an emulated arch both dwarf the boot itself. - Colima / Docker Desktop / Podman: all fine for
unifi-network.unifi-os-serveradditionally needs cgroup v2 and the runtime contract below; Colima works out of the box. sysbox-runc can run systemd containers without the explicit capability list — an option, not a requirement.
Some orchestrators start a container for you and hand back only a URL. CI
services (Woodpecker, GitLab) do it, Kubernetes does it, and none of them can
run docker inspect. Those callers used to reimplement readiness against an
API they do not own — typically a login poll, which goes green before the v2
surface and the demo fleet do.
Every image serves the verdict instead:
curl -fsS http://localhost:9099/readyz # 200 ready, 503 not yet
until curl -fsS http://host:9099/readyz >/dev/null; do sleep 2; donereadinessProbe: # Kubernetes
httpGet: { path: /readyz, port: 9099 }
periodSeconds: 5
failureThreshold: 60It answers 503 rather than refusing the connection from the moment the container starts, so "not ready yet" never has to be told apart from "wrong host". Poll it as fast as you like: the probe underneath runs at most once every two seconds however many callers ask, because its first stage is a login and UniFi rate-limits those globally.
Set READYZ_PORT to move it, READYZ_DISABLE=true to switch it off.
ghcr.io/jamesbraid/unifi-os-server runs the full UOS stack with systemd
as PID 1, which needs an explicit runtime contract — capability list (no
privileged mode), host cgroup namespace with /sys/fs/cgroup mounted rw,
and a tmpfs set. unifi-os/examples/docker-compose.yml is the complete,
copy-pasteable version of it.
UniFi OS exposes two independent API surfaces, each with its own admin, and the two test variants target one each:
- UOS ucore API (
:443, HTTPS) — the OS itself (/api/auth/login,/api/users/self, settings, backups, updates). The future-proof surface. The-seededvariant seeds an Owner here headlessly. The platform version is readable pre-auth atGET /api/system→firmwareVersion(e.g.5.1.21) — no login needed. Don't conflate it with the Network App version below: on-sim,7443reports the Network App version — the current pinned release, not the installer's bundle — never the platform's. - Network Application API (
127.0.0.1:8081loopback; behind UOS SSO externally) — the classic UniFi controller API that go-unifi and terraform-provider-unifi target today.UOS_NETWORK_DIRECT=true(default in-sim) exposes it on port 7443 as plain HTTP via a systemd socket proxy, so tests hit it directly with no SSO dance:
TAG=sim docker compose -f unifi-os/examples/docker-compose.yml up --wait
curl -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}' http://localhost:7443/api/loginThe sim healthcheck reports healthy once that login answers rc: ok, the v2
API surface stops 5xxing, and the demo fleet is fully populated. Those three
land at different times during boot, so reading stat/device or a v2
endpoint the instant the container goes healthy is safe — no poll of your
own. The expected device count follows DEMO_NUM_UAP / DEMO_NUM_UGW /
DEMO_NUM_USW. Set SIM_EXPECT_DEVICES to override the total outright.
Note 7443 is plain HTTP: an HTTPS client gets a TLS-handshake error
(SSL: WRONG_VERSION_NUMBER), not a redirect.
The -sim (Network App demo, with devices) and -seeded (UOS-native owner)
paths are mutually exclusive within one container — a demo Network App
is already "installed", so unifi-core's /api/setup can't drive it. Run the
variant that matches the API surface you're testing.
- The
network/entrypoint and healthchecks are written against Ubiquiti's ownunifi.initandunifi-network-service-helperfrom the deb. An earlier version was vendored from jacobalberty/unifi-docker (MIT); the Ubuntu 24.04 / Java 25 / MongoDB 6 modernization of that build is by Joshua Stark (jacobalberty/unifi-docker#903). Both remain in the git history with their original authorship. - The
unifi-os/extraction approach and single-volume entrypoint layout are adapted from toquanghieu/unifi-os-server-docker (MIT). The direct-API-port idea is from unihosted/unifi-os-server-docker (concept only — reimplemented from scratch here); lemker/unifi-os-server (AGPL-3.0) informed the runtime contract, ideas only, no code. - Images download official Ubiquiti artifacts at build time; artifact URLs are pinned and sha256-verified. Nothing Ubiquiti-proprietary is redistributed in this repository.
- UniFi is a trademark of Ubiquiti Inc. This project is not affiliated with or endorsed by Ubiquiti.
Project license: MIT.
