UniFi Network Application and MongoDB pod

An app and its database in one pod, with the passwords in podman secrets instead of the unit files.

  • Pod with several containers
  • Rootless: systemctl --user
  • Tested with podman 5.0 and newer

Last updated

Updated : added health checks and a Caddy block that fixes the 403 on login.

In short: The linuxserver UniFi image needs an external MongoDB. Put both in unifi.pod: the app reaches the database on localhost:27017 and the database port is never published. The passwords live in two podman secrets that are injected as environment variables, and an init script creates the app's database user on the first start. Pin MongoDB to a major version; mongo:8.0 needs UniFi Network Application 9.0 or newer.

The quadlet files

Every quadlet file below passes podman's quadlet generator and systemd-analyze verify on podman 5.0 and the current release. Personal values are placeholders: replace example.com, /srv/... paths and the time zone with your own.

unifi.pod

Save as ~/.config/containers/systemd/unifi.pod

[Unit]
Description=UniFi Network Application pod

[Pod]
PodName=unifi
# Web UI (https)
PublishPort=8443:8443
# Device inform: must stay 8080 on both sides
PublishPort=8080:8080
# STUN
PublishPort=3478:3478/udp
# Device discovery
PublishPort=10001:10001/udp

unifi-db.container

Save as ~/.config/containers/systemd/unifi-db.container

[Unit]
Description=MongoDB for the UniFi Network Application

[Container]
# Pin the major version: MongoDB does not upgrade across major versions on its own.
Image=docker.io/library/mongo:8.0
ContainerName=unifi-db
Pod=unifi.pod
Volume=unifi-db.volume:/data/db
Volume=%h/.config/containers/unifi/init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro,Z
Environment=MONGO_INITDB_ROOT_USERNAME=root
Environment=MONGO_USER=unifi
Environment=MONGO_DBNAME=unifi
Environment=MONGO_AUTHSOURCE=admin
Secret=unifi-mongo-root,type=env,target=MONGO_INITDB_ROOT_PASSWORD
Secret=unifi-mongo-pass,type=env,target=MONGO_PASS
HealthCmd=mongosh --quiet --norc --eval 'db.adminCommand({ping:1}).ok'
HealthInterval=2m
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=2m
HealthOnFailure=kill

[Service]
Restart=always
TimeoutStartSec=900

[Install]
WantedBy=default.target

unifi-db.volume

Save as ~/.config/containers/systemd/unifi-db.volume

[Volume]
VolumeName=unifi-db

unifi-app.container

Save as ~/.config/containers/systemd/unifi-app.container

[Unit]
Description=UniFi Network Application
Requires=unifi-db.service
After=unifi-db.service

[Container]
Image=lscr.io/linuxserver/unifi-network-application:latest
ContainerName=unifi-app
Pod=unifi.pod
AutoUpdate=registry
Volume=unifi-app.volume:/config
Environment=TZ=Etc/UTC
# Containers in a pod share localhost, so the database is not reachable by name.
Environment=MONGO_HOST=localhost
Environment=MONGO_PORT=27017
Environment=MONGO_USER=unifi
Environment=MONGO_DBNAME=unifi
Environment=MONGO_AUTHSOURCE=admin
Secret=unifi-mongo-pass,type=env,target=MONGO_PASS
HealthCmd=curl -fsk -o /dev/null https://127.0.0.1:8443/status
HealthInterval=2m
HealthTimeout=15s
HealthRetries=3
HealthStartPeriod=5m
HealthOnFailure=kill

[Service]
Restart=always
TimeoutStartSec=900

[Install]
WantedBy=default.target

unifi-app.volume

Save as ~/.config/containers/systemd/unifi-app.volume

[Volume]
VolumeName=unifi-app

init-mongo.sh

Save as ~/.config/containers/unifi/init-mongo.sh

#!/bin/bash

if which mongosh > /dev/null 2>&1; then
  mongo_init_bin='mongosh'
else
  mongo_init_bin='mongo'
fi
"${mongo_init_bin}" <<EOF
use ${MONGO_AUTHSOURCE}
db.auth("${MONGO_INITDB_ROOT_USERNAME}", "${MONGO_INITDB_ROOT_PASSWORD}")
db.createUser({
  user: "${MONGO_USER}",
  pwd: "${MONGO_PASS}",
  roles: [
    "clusterMonitor",
    { db: "${MONGO_DBNAME}", role: "dbOwner" },
    { db: "${MONGO_DBNAME}_stat", role: "dbOwner" },
    { db: "${MONGO_DBNAME}_audit", role: "dbOwner" },
    { db: "${MONGO_DBNAME}_restore", role: "dbOwner" }
  ]
})
EOF

Caddyfile

Save as /etc/caddy/Caddyfile

unifi.example.com {
	reverse_proxy https://10.0.0.10:8443 {
		# The controller answers the login with 403 when Host and Origin do not
		# match its own address, so send the upstream's address in both.
		header_up Host {upstream_hostport}
		header_up Origin https://{upstream_hostport}
		transport http {
			# The controller uses a self-signed certificate.
			tls_insecure_skip_verify
		}
	}
}

Why is it built this way?

This is the general app-plus-database shape, shown with the UniFi Network Application because the linuxserver image needs an external MongoDB, and that setup trips many people up. Swap the images and variables and the same five files run any app with PostgreSQL, MariaDB or Redis.

Why a pod. The app and its database always run together. Inside unifi.pod the app connects to localhost:27017, and the pod publishes only the UniFi ports: the database is never reachable from your network. Starting unifi-pod.service starts both containers.

Start order. Requires=unifi-db.service and After=unifi-db.service in the app's [Unit] section start MongoDB first, and stop the app if the database unit fails. The app retries its database connection on its own while MongoDB finishes starting.

Secrets, not passwords in files. Both passwords are podman secrets injected as environment variables with Secret=…,type=env,target=…. The unit files contain no password, so you can keep them in git. The same unifi-mongo-pass secret feeds the init script in the database and the connection settings in the app, so they always match.

The init script. The official MongoDB image runs every script in /docker-entrypoint-initdb.d/ once, when /data/db is empty. init-mongo.sh is the script from the linuxserver documentation, unchanged: it creates the unifi user with the roles the application needs. %h in the Volume= line is systemd's placeholder for your home directory.

Different update rules. MongoDB is pinned to mongo:8.0 without AutoUpdate=, because a major-version jump needs a manual migration and a database should change only after a backup. The app uses latest with AutoUpdate=registry, like the linuxserver images in the media stack pod.

Health checks. The app is checked on its own /status page and the database with a mongosh ping, every two minutes. A hung controller or database is killed and restarted by systemd; HealthStartPeriod=5m covers the controller's slow first start.

Behind Caddy. The controller speaks HTTPS with a self-signed certificate on port 8443, so Caddy connects with tls_insecure_skip_verify on the LAN hop only. The controller also compares the Host and Origin headers of the login request with its own address and answers 403 when they differ; the two header_up lines send the upstream's address instead. Device inform traffic on port 8080 goes straight to the pod, never through Caddy.

How do I start it?

Run these as your normal user, not as root. Quadlet reads ~/.config/containers/systemd/ for user services.

mkdir -p ~/.config/containers/systemd ~/.config/containers/unifi
# save init-mongo.sh in ~/.config/containers/unifi/, the other files in ~/.config/containers/systemd/
# two random passwords; tr strips the newline so it does not end up in the password
openssl rand -hex 24 | tr -d '\n' | podman secret create unifi-mongo-root -
openssl rand -hex 24 | tr -d '\n' | podman secret create unifi-mongo-pass -
systemctl --user daemon-reload
systemctl --user start unifi-pod.service
loginctl enable-linger "$USER"
# then open https://<server>:8443 and run the setup wizard

Check the result with systemctl --user status and the logs with journalctl --user -u followed by the service name. To turn a docker-compose file of your own into files like these, use the compose to quadlet converter.

Common mistakes

MongoDB exits with Illegal instruction (core dumped)

MongoDB 5.0 and newer need a CPU with AVX on x86_64, which some Celeron and Pentium models lack. Use Image=docker.io/library/mongo:4.4 on those machines; the UniFi Network Application still supports it.

UniFi cannot log in to MongoDB: Authentication failed

The init script only runs when /data/db is empty. If the database first started without it, or with a different password, stop the pod, delete the data (podman volume rm unifi-db) and start again. Also check that the secret has no trailing newline.

Access points stay on Adopting or never appear

The controller announces its container address, which your devices cannot reach. In the UniFi settings, enable Inform Host override and set it to the server's LAN address, or run set-inform http://<server>:8080/inform over SSH on the device. Keep port 8080 identical on both sides.

Login through a reverse proxy fails with 403 Forbidden

The controller rejects the login request when the Host and Origin headers do not match its own address. In Caddy, add header_up Host {upstream_hostport} and header_up Origin https://{upstream_hostport} inside the reverse_proxy block, as in the Caddyfile above, then reload Caddy.

MONGO_HOST=unifi-db does not resolve

Containers in a pod share one network namespace and reach each other on localhost, not by container name. Set MONGO_HOST=localhost.

Frequently asked questions

Which MongoDB version does the UniFi Network Application support?

According to the linuxserver image documentation, version 8.1 of the application supports MongoDB 3.6 through 7.0, and version 9.0 adds MongoDB 8.0. Pin the major version in the image tag, because MongoDB does not upgrade across major versions by itself.

Why run the app and its database in one pod?

They always start and stop together, and in a pod the database listens on localhost only, so its port is never published to your network. One unifi-pod.service starts both; Requires= and After= make sure MongoDB starts before the app.

How do I keep database passwords out of quadlet files?

Create a podman secret (podman secret create name -) and reference it with Secret=name,type=env,target=VARIABLE. The value is injected as an environment variable when the container starts, so it never appears in the unit file or the generated service.

Can I use this layout for other apps with a database?

Yes. Replace the images and variables: for example an app with PostgreSQL uses docker.io/library/postgres:17 with Secret=db-pass,type=env,target=POSTGRES_PASSWORD, and the app connects to localhost:5432. The pod, volumes and dependencies stay the same.

How do I back up the UniFi controller?

Use the application's built-in automatic backups, which it stores in its config volume, and copy that volume off the host: podman volume export unifi-app -o unifi-app.tar. Run the export from a systemd timer if you want it daily.