Skip to content

Instantly share code, notes, and snippets.

@usrbinkat
Last active May 25, 2025 20:38
Show Gist options
  • Save usrbinkat/512594eb81a01f974b54ec9263c627b3 to your computer and use it in GitHub Desktop.
Save usrbinkat/512594eb81a01f974b54ec9263c627b3 to your computer and use it in GitHub Desktop.
Ubiquiti Unifi Access Point Network Application Controller on Kubernetes

K8s UniFi Network Application Deployment

NOTICE: under active development, currently facing pvc permissions issues

Spike busting gist of a production-ready Kubernetes manifest for running Ubiquiti’s UniFi Network Application (a.k.a. UniFi Controller) backed by a MongoDB database. All data is persisted in PersistentVolumeClaims (PVCs), and both containers ultimately run as non-root for better security.


Overview

UniFi Network Application (sometimes called “UniFi Controller”) is the central management software for Ubiquiti access points, switches, routers, and more. It stores all configuration, device adoption data, and statistics in a MongoDB database.

Source Docs

References:

Key Features of this Deployment

  1. Persistent Storage
    All UniFi and MongoDB data is stored in PVCs. This ensures any restarts or rescheduling on different nodes will retain your network configuration and statistics.

  2. Non-Root Operation
    The main containers run as non-root to reduce the attack surface and comply with many security standards. We use initContainers to fix volume permissions ahead of time, allowing the main processes to run under user IDs 1000 (UniFi) and 1001 (Mongo).

  3. Minimal Privilege
    We set allowPrivilegeEscalation: false and drop all capabilities in the main containers, relying only on ephemeral root in initContainers to handle volume ownership. This approach aligns with the “least privileged” principle while still ensuring data directories are writable.

  4. PodSecurity
    The example is placed in a privileged namespace to enable initContainers to run as root. If you require a stricter PodSecurity posture, additional steps (like manually pre-chowning the volumes on the host) would be necessary.

  5. Configurable Memory Usage
    UniFi uses environment variables to control Java heap usage (MEM_LIMIT and MEM_STARTUP). Adjust them for your environment.


Prerequisites

  • Kubernetes cluster with a working StorageClass
    • In the examples, we use ssd as the storageClassName. Replace it with the relevant StorageClass name for your cluster.
  • LoadBalancer capability (for example, MetalLB, Cilium with ARP mode, or a cloud provider) if you want an external IP. Otherwise, change Service type to NodePort or ClusterIP.
  • kubectl or a similar tool to apply manifests.

Quick Start

  1. Save this deployment yaml locally.
  2. Review and edit deployment.yaml to fit your cluster:
    • Change the StorageClass references if needed.
    • Change the base64 password in the Secret.
  3. Apply the manifest:
    kubectl apply -f deployment.yaml
  4. Watch the pods come up:
    kubectl -n unifi get pods -w
    You should see something like:
    NAME                                READY   STATUS    RESTARTS   AGE
    unifi-controller-...                1/1     Running   0          1m
    unifi-db-...                        1/1     Running   0          1m
    
  5. Once both pods are running, the UniFi Network Application is available on the unifi-controller Service. If using a LoadBalancer, check:
    kubectl -n unifi get svc unifi-controller
    to see the external IP (or the node ports if using NodePort).

File-by-File Explanation

Everything is consolidated in a single deployment.yaml for convenience. Within it, you’ll find:

  1. Namespace:

    kind: Namespace
    metadata:
      name: unifi
      labels:
        pod-security.kubernetes.io/enforce: privileged
        ...
    • We label it “privileged” so the initContainers can run as root. If you prefer “baseline,” be sure you’re allowed to run root initContainers. If you choose “restricted,” you must handle volume ownership by other means (e.g., manual chown on the host or external provisioning).
  2. PVCs:

    kind: PersistentVolumeClaim
    metadata:
      name: unifi-data
    ...
    kind: PersistentVolumeClaim
    metadata:
      name: mongo-data
    ...
    • These request 5Gi each from the ssd StorageClass (example). Adjust as needed.
  3. Secret for MongoDB Credentials:

    kind: Secret
    metadata:
      name: unifi-mongo-credentials
    data:
      password: "c3VwZXJzZWNyZXQK"
    • The example password is supersecret (base64-encoded). To generate your own:
      echo -n "mypassword" | base64
      Then replace the string in the manifest.
  4. UniFi Controller Deployment

    • An initContainer fix-permissions-unifi runs as root to chown the /config volume to user 1000.
    • The main container then runs as user 1000 (runAsUser: 1000), with no extra capabilities.
    • The environment variables MONGO_... point to the external MongoDB service.
  5. UniFi Controller Service

    • Exposes ports for discovery, STUN, syslog, HTTP/HTTPS, etc. Type is LoadBalancer by default.
  6. MongoDB Deployment

    • Another initContainer fix-mongo-permissions sets correct ownership (1001:1001) on /bitnami/mongodb.
    • The main Bitnami Mongo container runs as UID=1001, referencing the password from the same Secret.
    • By default, we create a root user and a separate unifi user with its own database.
    • If needed, advanced scripts (like granting additional roles for unifi_stat) can be placed in a ConfigMap mounted into /docker-entrypoint-initdb.d.
  7. MongoDB Service

    • Exposes port 27017. If only used internally, you can keep it as a ClusterIP.

Security Considerations

  • initContainers as root: This design uses ephemeral root just long enough to fix file ownership. After that, the main containers run as unprivileged users. This is typically a good balance of security and usability.
  • Network: The UniFi controller listens on multiple ports. If you only want to expose the HTTPS UI (port 8443) and other essential ports, you can remove the ones you don’t need from the Service to reduce attack surface.
  • PodSecurity: We label the namespace as privileged. If you want a stricter policy, you must ensure volume ownership is handled externally.
  • Secrets: By default, the password is stored in a basic Kubernetes Secret. For advanced scenarios, consider integrating with Pulumi ESC, HashiCorp Vault, or other secret managers.

Troubleshooting

  1. Pods stuck in CrashLoopBackOff

    • Likely a permission error on the PVC volume. Check logs:
      kubectl -n unifi logs <pod> -f
      If you see mkdir: cannot create directory... Permission denied, ensure the initContainer or volume ownership is correct, or that your StorageClass is not preventing writes from container root.
  2. Cannot connect to the UniFi UI

    • Verify the Service type is correct and that the external IP is allocated.
    • If using NodePort, visit NodeIP:NodePort.
  3. Mongo user/permissions

  4. Memory constraints

    • The MEM_LIMIT and MEM_STARTUP environment variables (in MB) define how much memory the UniFi Java process is allowed to use. Adjust as necessary if you have many devices or a large data set.

Maintenance

  • Upgrades:

    • Check for new versions of the UniFi image: lscr.io/linuxserver/unifi-network-application
    • Check for new versions of the Bitnami MongoDB image.
    • Update your deployment.yaml image tags, then reapply:
      kubectl apply -f deployment.yaml
    • Kubernetes will gracefully roll out the new versions.
  • Backups:

    • Mongo: Consider a scheduled backup job or snapshot of the PVC.
    • UniFi: The built-in UniFi UI can export a backup, or you can snapshot the unifi-data PVC.
###############################################################################
# 0) Namespace
###############################################################################
apiVersion: v1
kind: Namespace
metadata:
name: unifi
labels:
# Essentially disables PodSecurity for this namespace
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/warn: privileged
pod-security.kubernetes.io/audit: privileged
---
###############################################################################
# 1) PersistentVolumeClaim for UniFi data
###############################################################################
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: unifi-data
namespace: unifi
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: ssd
---
###############################################################################
# 2) PersistentVolumeClaim for Mongo data
###############################################################################
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongo-data
namespace: unifi
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: ssd
---
###############################################################################
# 3) Secret containing MongoDB credentials
###############################################################################
apiVersion: v1
kind: Secret
metadata:
name: unifi-mongo-credentials
namespace: unifi
type: Opaque
data:
# Example password: `echo supersecret | base64 -o-` => 'c3VwZXJzZWNyZXQK'
password: "c3VwZXJzZWNyZXQK"
---
###############################################################################
# 4) UniFi Controller Deployment & Service
###############################################################################
apiVersion: apps/v1
kind: Deployment
metadata:
name: unifi-controller
namespace: unifi
spec:
replicas: 1
selector:
matchLabels:
app: unifi-controller
template:
metadata:
labels:
app: unifi-controller
spec:
containers:
- name: unifi-controller
image: lscr.io/linuxserver/unifi-network-application:8.6.9
imagePullPolicy: Always
securityContext:
runAsUser: 0
runAsGroup: 0
privileged: true
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "Etc/UTC"
- name: MEM_LIMIT
value: "1024"
- name: MEM_STARTUP
value: "1024"
# Mongo connection
- name: MONGO_USER
value: "unifi"
- name: MONGO_PASS
valueFrom:
secretKeyRef:
name: unifi-mongo-credentials
key: password
- name: MONGO_HOST
value: "unifi-db.unifi.svc.cluster.local"
- name: MONGO_PORT
value: "27017"
- name: MONGO_DBNAME
value: "unifi"
- name: MONGO_AUTHSOURCE
value: "unifi"
ports:
- name: unifi-discovery
containerPort: 10001
protocol: UDP
- name: stun
containerPort: 3478
protocol: UDP
- name: syslog
containerPort: 5514
protocol: UDP
- name: https-ui
containerPort: 8843
protocol: TCP
- name: api
containerPort: 8443
protocol: TCP
- name: http-portal
containerPort: 8880
protocol: TCP
- name: controller
containerPort: 8080
protocol: TCP
- name: speed-test
containerPort: 6789
protocol: TCP
volumeMounts:
- name: unifi-data
mountPath: /config
volumes:
- name: unifi-data
persistentVolumeClaim:
claimName: unifi-data
---
apiVersion: v1
kind: Service
metadata:
name: unifi-controller
namespace: unifi
spec:
type: LoadBalancer
selector:
app: unifi-controller
ports:
- name: unifi-discovery
port: 10001
protocol: UDP
targetPort: 10001
- name: stun
port: 3478
protocol: UDP
targetPort: 3478
- name: syslog
port: 5514
protocol: UDP
targetPort: 5514
- name: https-ui
port: 8843
protocol: TCP
targetPort: 8843
- name: api
port: 8443
protocol: TCP
targetPort: 8443
- name: http-portal
port: 8880
protocol: TCP
targetPort: 8880
- name: controller
port: 8080
protocol: TCP
targetPort: 8080
- name: speed-test
port: 6789
protocol: TCP
targetPort: 6789
---
###############################################################################
# 6) MongoDB Deployment & Service (Bitnami container)
###############################################################################
apiVersion: apps/v1
kind: Deployment
metadata:
name: unifi-db
namespace: unifi
spec:
replicas: 1
selector:
matchLabels:
app: unifi-db
template:
metadata:
labels:
app: unifi-db
spec:
initContainers:
- name: fix-permissions
image: bitnami/mongodb:4.4.10
securityContext:
runAsUser: 0
runAsGroup: 0
privileged: true
command:
- /bin/bash
- -c
- |
echo "[DEBUG] fix-permissions initContainer started as user $(id -u):$(id -g)."
echo "[DEBUG] Current ownership in /bitnami/mongodb:"
ls -la /bitnami/mongodb || true
echo "[DEBUG] Setting everything wide open with chown/chmod."
chown -R root:root /bitnami/mongodb || true
chmod -R 0777 /bitnami/mongodb || true
echo "[DEBUG] Post-chown/chmod listing:"
ls -la /bitnami/mongodb
echo "[DEBUG] fix-permissions complete. Next container..."
volumeMounts:
- name: mongo-data
mountPath: /bitnami/mongodb
containers:
- name: mongo
image: bitnami/mongodb:4.4.10
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
runAsGroup: 0
privileged: true
env:
- name: BITNAMI_DEBUG
value: "true"
- name: MONGODB_DATABASE
value: "unifi"
- name: MONGODB_USERNAME
value: "unifi"
- name: MONGODB_PASSWORD
valueFrom:
secretKeyRef:
name: unifi-mongo-credentials
key: password
- name: MONGODB_ROOT_USER
value: "root"
- name: MONGODB_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: unifi-mongo-credentials
key: password
- name: ALLOW_EMPTY_PASSWORD
value: "no"
volumeMounts:
- name: mongo-data
mountPath: /bitnami/mongodb
- name: mongo-init-scripts
mountPath: /docker-entrypoint-initdb.d
readOnly: true
volumes:
- name: mongo-data
persistentVolumeClaim:
claimName: mongo-data
- name: mongo-init-scripts
configMap:
name: mongo-init-grant
items:
- key: zzz-grant-unifi-stat.sh
path: zzz-grant-unifi-stat.sh
---
apiVersion: v1
kind: Service
metadata:
name: unifi-db
namespace: unifi
spec:
selector:
app: unifi-db
ports:
- name: mongodb
port: 27017
targetPort: 27017
protocol: TCP
---
###############################################################################
# 1) ConfigMap: post-init script to grant 'unifi' dbOwner on 'unifi_stat'
# x) This is a hacky workaround, Ubiquiti, do better.
###############################################################################
apiVersion: v1
kind: ConfigMap
metadata:
name: mongo-init-grant
namespace: unifi
data:
zzz-grant-unifi-stat.sh: |
#!/bin/bash
echo "[INFO] Granting 'unifi' user dbOwner on 'unifi_stat'..."
echo "[INFO] Running as user: $(id -u):$(id -g)"
ls -la /bitnami/mongodb || true
mongo --username "${MONGODB_ROOT_USER}" --password "${MONGODB_ROOT_PASSWORD}" <<EOF
use unifi
db.grantRolesToUser("unifi", [
{ role: "dbOwner", db: "unifi_stat" }
]);
EOF
echo "[INFO] Done granting dbOwner on unifi_stat."
###############################################################################
# NAMESPACE: Allows ephemeral root/privileged pods
###############################################################################
apiVersion: v1
kind: Namespace
metadata:
name: unifi
labels:
# This effectively disables the strict "restricted" PodSecurity
# so initContainers can run as root to fix volume perms.
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/warn: privileged
pod-security.kubernetes.io/audit: privileged
---
###############################################################################
# PVC for Unifi data
###############################################################################
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: unifi-data
namespace: unifi
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
# Adjust to match your real storage class if needed:
storageClassName: ssd
---
###############################################################################
# PVC for Mongo data
###############################################################################
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongo-data
namespace: unifi
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
# Adjust to match your real storage class if needed:
storageClassName: ssd
---
###############################################################################
# Secret with MongoDB credentials
###############################################################################
# To generate your own password base64:
# echo -n "mynewpassword" | base64
# Then replace the below 'c3VwZXJzZWNyZXQK' with your new base64 string.
###############################################################################
apiVersion: v1
kind: Secret
metadata:
name: unifi-mongo-credentials
namespace: unifi
type: Opaque
data:
password: "c3VwZXJzZWNyZXQK"
---
###############################################################################
# UniFi Controller Deployment
###############################################################################
apiVersion: apps/v1
kind: Deployment
metadata:
name: unifi-controller
namespace: unifi
spec:
replicas: 1
selector:
matchLabels:
app: unifi-controller
template:
metadata:
labels:
app: unifi-controller
spec:
# We fix ownership on /config before the main container runs.
initContainers:
- name: fix-permissions-unifi
image: busybox:1.36
securityContext:
runAsUser: 0
allowPrivilegeEscalation: true
command:
- sh
- -c
- |
echo "[INFO] initContainer: chown -R 1000:1000 /config"
chown -R 1000:1000 /config || true
chmod -R 755 /config || true
volumeMounts:
- name: unifi-data
mountPath: /config
containers:
- name: unifi-controller
image: lscr.io/linuxserver/unifi-network-application:8.6.9
imagePullPolicy: Always
# This container will run as UID=1000 after init fixes perms
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
env:
# Container user and group match securityContext
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "Etc/UTC"
# Memory constraints
- name: MEM_LIMIT
value: "1024"
- name: MEM_STARTUP
value: "1024"
# Mongo connection details
- name: MONGO_USER
value: "unifi"
- name: MONGO_PASS
valueFrom:
secretKeyRef:
name: unifi-mongo-credentials
key: password
- name: MONGO_HOST
value: "unifi-db.unifi.svc.cluster.local"
- name: MONGO_PORT
value: "27017"
- name: MONGO_DBNAME
value: "unifi"
- name: MONGO_AUTHSOURCE
value: "unifi"
ports:
- name: unifi-discovery
containerPort: 10001
protocol: UDP
- name: stun
containerPort: 3478
protocol: UDP
- name: syslog
containerPort: 5514
protocol: UDP
- name: https-ui
containerPort: 8843
protocol: TCP
- name: api
containerPort: 8443
protocol: TCP
- name: http-portal
containerPort: 8880
protocol: TCP
- name: controller
containerPort: 8080
protocol: TCP
- name: speed-test
containerPort: 6789
protocol: TCP
volumeMounts:
- name: unifi-data
mountPath: /config
volumes:
- name: unifi-data
persistentVolumeClaim:
claimName: unifi-data
---
###############################################################################
# UniFi Controller Service
###############################################################################
apiVersion: v1
kind: Service
metadata:
name: unifi-controller
namespace: unifi
spec:
# If your CNI supports "LoadBalancer" (like MetalLB or Cilium w/ ARP),
# this will get an IP. Otherwise, set "type: NodePort" or "ClusterIP".
type: LoadBalancer
selector:
app: unifi-controller
ports:
- name: unifi-discovery
port: 10001
protocol: UDP
targetPort: 10001
- name: stun
port: 3478
protocol: UDP
targetPort: 3478
- name: syslog
port: 5514
protocol: UDP
targetPort: 5514
- name: https-ui
port: 8843
protocol: TCP
targetPort: 8843
- name: api
port: 8443
protocol: TCP
targetPort: 8443
- name: http-portal
port: 8880
protocol: TCP
targetPort: 8880
- name: controller
port: 8080
protocol: TCP
targetPort: 8080
- name: speed-test
port: 6789
protocol: TCP
targetPort: 6789
---
###############################################################################
# MongoDB Deployment (Bitnami)
###############################################################################
apiVersion: apps/v1
kind: Deployment
metadata:
name: unifi-db
namespace: unifi
spec:
replicas: 1
selector:
matchLabels:
app: unifi-db
template:
metadata:
labels:
app: unifi-db
spec:
# This initContainer ensures /bitnami/mongodb is owned by user=1001
initContainers:
- name: fix-mongo-permissions
image: busybox:1.36
securityContext:
runAsUser: 0
allowPrivilegeEscalation: true
command:
- sh
- -c
- |
echo "[INFO] initContainer: chown -R 1001:1001 /bitnami/mongodb"
chown -R 1001:1001 /bitnami/mongodb || true
chmod -R 755 /bitnami/mongodb || true
echo "[INFO] init complete."
volumeMounts:
- name: mongo-data
mountPath: /bitnami/mongodb
containers:
- name: mongo
image: bitnami/mongodb:4.4.10
imagePullPolicy: IfNotPresent
# The main container runs as user=1001
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
env:
# root user (you can rename it) and password
- name: MONGODB_ROOT_USER
value: "root"
- name: MONGODB_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: unifi-mongo-credentials
key: password
# Main unifi user and database
- name: MONGODB_USERNAME
value: "unifi"
- name: MONGODB_PASSWORD
valueFrom:
secretKeyRef:
name: unifi-mongo-credentials
key: password
- name: MONGODB_DATABASE
value: "unifi"
# Disallow empty password just for safety
- name: ALLOW_EMPTY_PASSWORD
value: "no"
ports:
- containerPort: 27017
volumeMounts:
- name: mongo-data
mountPath: /bitnami/mongodb
volumes:
- name: mongo-data
persistentVolumeClaim:
claimName: mongo-data
---
###############################################################################
# MongoDB Service
###############################################################################
apiVersion: v1
kind: Service
metadata:
name: unifi-db
namespace: unifi
spec:
selector:
app: unifi-db
ports:
- name: mongodb
port: 27017
targetPort: 27017
protocol: TCP
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment