UniFi OS Server on nixos

I was playing around with unifi nixos module and then I got the message that the unifi-controller software will not be updated anymore and only the new unifi os server / uosserver will be supported. Since i found the blog post Running UniFi OS Server in Docker | UniHosted , i knew that it was basically a podman oci image. Conclusion, here is a for me working nixos-module:

module.nix:

{
  config,
  lib,
  pkgs,
  ...
}: let
  inherit
    (lib)
    mkEnableOption
    mkIf
    mkOption
    types
    ;

  cfg = config.services.unifi-os-server;
  stateDir = "/var/lib/unifi-os";

  # Capture unifi-core stdout/stderr to readable files
  # (the container's journal is only accessible as root)
  ucoreDebug = pkgs.writeText "unifi-core-debug.conf" ''
    [Service]
    StandardOutput=append:/data/unifi-core/logs/stdout.log
    StandardError=append:/data/unifi-core/logs/stderr.log
  '';

  # Fix missing directories that services expect but don't create on first run.
  ucorePreStartFix = pkgs.writeText "unifi-core-prestart-fix.conf" ''
    [Service]
    ExecStartPre=-/bin/mkdir -p /data/unifi-core/config/http
    ExecStartPre=-/bin/mkdir -p /var/log/nginx
  '';

  # MongoDB needs writable log and data dirs; + runs as root regardless of User=
  mongoPreStartFix = pkgs.writeText "mongodb-prestart-fix.conf" ''
    [Service]
    ExecStartPre=+/bin/bash -c "mkdir -p /var/log/mongodb && chown mongodb:mongodb /var/log/mongodb /var/lib/mongodb"
  '';
in {
  # reverse engineered via
  # https://www.unihosted.com/blog/running-unifi-os-server-in-docker
  options.services.unifi-os-server = {
    enable = mkEnableOption "UniFi OS Server container (podman)";

    package = mkOption {
      type = types.package;
      description = ''
        Package containing the extracted UniFi OS Server OCI archive at
        `image.tar`.  Build with:
        `pkgs.callPackage ./pkgs/unifi-os-server-image { sha256 = "…"; }`
      '';
    };

    imageTag = mkOption {
      type = types.str;
      description = ''
        Exact image name:tag embedded in `image.tar`.
        Must match the repository:tag inside the archive.
      '';
      example = "uosserver:0.0.54";
    };

    openFirewall = mkOption {
      type = types.bool;
      default = false;
      description = ''
        Whether or not to open the minimum required ports on the firewall.

        This is necessary to allow firmware upgrades and device discovery to
        work. For remote login, you should additionally open (or forward) port
        8443.
      '';
    };

    environment = mkOption {
      type = types.attrsOf types.str;
      default = {};
      description = "Additional environment variables for the container.";
    };

    extraVolumes = mkOption {
      type = types.listOf types.str;
      default = [];
      example = ["/etc/ssl/certs:/etc/rabbitmq/ssl:ro"];
      description = "Additional bind mounts beyond the defaults.";
    };

    extraOptions = mkOption {
      type = types.listOf types.str;
      default = [];
      description = "Extra arguments passed to podman.";
    };
  };

  config = mkIf cfg.enable {
    virtualisation.podman.enable = true;
    virtualisation.oci-containers.backend = "podman";

    # https://www.crosstalksolutions.com/complete-unifi-os-server-installation-on-linux-best-practices/
    networking.firewall = mkIf cfg.openFirewall {
      allowedTCPPorts = [
        443 # HTTPS portal
        8080 # UAP device inform
        8443 # Controller HTTPS
        8843 # HTTPS portal redirect
        8880 # HTTP portal redirect
        6789 # Mobile speed test
      ];
      allowedUDPPorts = [
        3478 # STUN
        10001 # Device discovery
      ];
    };

    systemd.services.podman-unifi-os-server = {
      # Make sure package upgrades trigger a service restart
      restartTriggers = [cfg.package];

      serviceConfig = {
        StateDirectory = [
          "unifi-os"
          "unifi-os/persistent"
          "unifi-os/data"
          "unifi-os/srv"
          "unifi-os/unifi"
          "unifi-os/mongodb"
        ];
        LogsDirectory = "unifi-os";
      };

      preStart = lib.mkAfter ''
        uuid_file="${stateDir}/data/uos_uuid"
        # The Java UniFi controller requires exactly UUID v5 (SHA-1 name-based).
        # Generate a stable v5 UUID derived from the machine-id.
        if ! grep -qP '^[0-9a-f]{8}-[0-9a-f]{4}-5' "$uuid_file" 2>/dev/null; then
          ${pkgs.util-linux}/bin/uuidgen -s -n @dns -N "$(cat /etc/machine-id)" > "$uuid_file"
        fi

      '';
    };

    virtualisation.oci-containers.containers.unifi-os-server = {
      image = cfg.imageTag;
      imageFile = pkgs.runCommand "unifi-os-image.tar" {} ''
        ln -s ${cfg.package}/image.tar $out
      '';
      autoStart = true;
      privileged = true;

      ports = [
        "443:443"
        "8080:8080"
        "8443:8443"
        "8843:8843"
        "8880:8880"
        "6789:6789"
        "3478:3478/udp"
        "10001:10001/udp"
      ];

      environment =
        {
          UOS_SYSTEM_IP = "127.0.0.1";
          UOS_SERVER_VERSION = cfg.package.version;
          FIRMWARE_PLATFORM =
            if pkgs.stdenv.hostPlatform.isAarch64
            then "linux-arm64"
            else "linux-x64";
        }
        // cfg.environment;

      volumes =
        [
          "${stateDir}/persistent:/persistent"
          "/var/log/unifi-os:/var/log"
          "${stateDir}/data:/data"
          "${stateDir}/srv:/srv"
          "${stateDir}/unifi:/var/lib/unifi"
          "${stateDir}/mongodb:/var/lib/mongodb"
          "${ucoreDebug}:/etc/systemd/system/unifi-core.service.d/debug.conf:ro"
          "${ucorePreStartFix}:/etc/systemd/system/unifi-core.service.d/prestart-fix.conf:ro"
          "${mongoPreStartFix}:/etc/systemd/system/mongodb.service.d/prestart-fix.conf:ro"
        ]
        ++ cfg.extraVolumes;

      extraOptions =
        [
          "--systemd=always"
          "--add-host=host.docker.internal:host-gateway"
        ]
        ++ cfg.extraOptions;
    };
  };
}

and since the image needs to fetched from somewhere here is a derivation for it:

{
  lib,
  stdenvNoCC,
  fetchurl,
  binwalk,
  coreutils,
  findutils,
  gnugrep,
  version ? "5.0.6",
  # url ? "https://fw-download.ubnt.com/data/unifi-os-server/df5b-linux-arm64-5.0.6-f35e944c-f4b6-4190-93a8-be61b96c58f4.6-arm64",
  url ? "https://fw-download.ubnt.com/data/unifi-os-server/1856-linux-x64-5.0.6-33f4990f-6c68-4e72-9d9c-477496c22450.6-x64",
  sha256,
}:
stdenvNoCC.mkDerivation rec {
  # reverse engineered via
  # https://www.unihosted.com/blog/running-unifi-os-server-in-docker
  pname = "unifi-os-server-image";
  inherit version;

  src = fetchurl {
    inherit url sha256;
  };

  nativeBuildInputs = [
    binwalk
    coreutils
    findutils
    gnugrep
  ];

  dontUnpack = true;

  installPhase = ''
    set -euo pipefail

    work="$PWD/work"
    mkdir -p "$work"
    cp "$src" "$work/unifi-os-installer"
    chmod u+w "$work/unifi-os-installer"
    cd "$work"

    binwalk -e ./unifi-os-installer >/dev/null

    image_tar="$(find . -type f -name image.tar | head -n1)"
    if [ -z "$image_tar" ]; then
      echo "Could not find embedded image.tar in UniFi OS installer" >&2
      exit 1
    fi

    mkdir -p "$out"
    cp "$image_tar" "$out/image.tar"
  '';

  meta = with lib; {
    description = "Extracted OCI image archive from the UniFi OS Server installer";
    homepage = "https://help.ui.com/hc/en-us/articles/34210126298775-Self-Hosting-UniFi";
    license = licenses.unfreeRedistributableFirmware;
    platforms = platforms.linux;
    sourceProvenance = with sourceTypes; [binaryNativeCode];
  };
}
  services.unifi-os-server = {
    enable = true;
    package = pkgs.callPackage ../../pkgs/unifi-os-server-image {
      sha256 = "sha256-IPoWR5GTiy7J1WgMEYdTxGo26qM2nO+U1c742pRo354=";
    };
    imageTag = "uosserver:0.0.54";
  };

am happy to get some feedback and whom ever this will help, i wish you a happy day.

EDITS: Since images are not always built in a sandbox a few tweaks were missing i added them and an service enable example
EDIT 5: fixed crashing mongodb

2 Likes

I got a very similar setup working this weekend. Everything seems ok from the web ui, but a BEAM vm is crashing once a minute:

Mar 10 09:27:00 foo systemd-coredump[1447218]: [🡕] Process 1447172 (beam.smp) of user 130 dumped core.
                                               Stack trace of thread 237073:
                                               #0  0x00007f2a9a5c4d61 n/a (/lib/x86_64-linux-gnu/libc-2.31.so + 0x38d61)
                                               ELF object binary architecture: AMD x86-64

Have you been experiencing this?

i looked at my logs and the last time i had the log was yesterday. Since then nothing, no complains / logs. I have changed many things since then but the latest version of my config doesn’t throw this error. I also had to cleanly build everything again. Maybe this fixed it for me. Do you have a reproducible way to trigger it?

I have modified this for my needs to make it easier to use and fit my own patterns more:

The package itself now extracts, eliminating the need for an intermediate package:

{
  lib,
  pkgs,
  ...
}:
let
  version = "5.0.6";
  url = "https://fw-download.ubnt.com/data/unifi-os-server/1856-linux-x64-${version}-33f4990f-6c68-4e72-9d9c-477496c22450.6-x64";
  sha256 = "sha256-IPoWR5GTiy7J1WgMEYdTxGo26qM2nO+U1c742pRo354=";
in
pkgs.stdenvNoCC.mkDerivation {
  # reverse engineered via
  # https://www.unihosted.com/blog/running-unifi-os-server-in-docker
  pname = "unifi-os-server-image";
  inherit version;

  src = pkgs.fetchurl {
    inherit url sha256;
  };

  nativeBuildInputs = with pkgs; [
    binwalk
    coreutils
    findutils
  ];

  dontUnpack = true;

  installPhase = ''
    set -euo pipefail

    work="$PWD/work"
    mkdir -p "$work"
    cp "$src" "$work/unifi-os-installer"
    chmod u+w "$work/unifi-os-installer"
    cd "$work"

    binwalk -e ./unifi-os-installer >/dev/null

    image_tar="$(find . -type f -name image.tar | head -n1)"
    if [ -z "$image_tar" ]; then
      echo "Could not find embedded image.tar in UniFi OS installer" >&2
      exit 1
    fi

    mkdir -p "$out"
    tar -xf "$image_tar" -C "$out"
  '';

  meta = with lib; {
    description = "Extracted OCI image archive from the UniFi OS Server installer";
    homepage = "https://help.ui.com/hc/en-us/articles/34210126298775-Self-Hosting-UniFi";
    license = licenses.unfreeRedistributableFirmware;
    platforms = platforms.linux;
    sourceProvenance = with sourceTypes; [ binaryNativeCode ];
  };
}

This one needs a bit more editing, but the oci section of my OCI wrapper maps to virtualisation.oci-containers.containers.unifi-os-server and the systemd section to systemd.services.podman-unifi-os-server pretty much 1:1

It also grabs the tag automatically from the image’s manifest, eliminating a manual update step.

Also the whole IPv4 finding can be removed since UOS_SYSTEM_IP = “127.0.0.1” works perfectly fine as a hardcoded value as well.

Note that my wrapper usually runs all my containers as their own user with their own subuid/subgid set. If you run podman itself as root, you can likely drop the “chown” for the cgroup folder entirely.

{
  config,
  lib,
  pkgs,
  foxDenLib,
  ...
}:
let
  user = config.users.users.unifi-os-server;
  svcConfig = config.foxDen.services.unifi-os-server;
  stateDir = user.home;

  # MongoDB needs writable log and data dirs; + runs as root regardless of User=
  mongoPreStartFix = pkgs.writeText "mongodb-prestart-fix.conf" ''
    [Service]
    ExecStartPre=+/bin/chown mongodb:mongodb /var/log/mongodb /var/lib/mongodb"
  '';

  dbusStartFix = pkgs.writeText "dbus-start-fix.conf" ''
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE busconfig SYSTEM "busconfig.dtd">
    <busconfig>
        <apparmor mode="disabled"/>
    </busconfig>
  '';

  name = "unifi-os-server";

  ifaceFirstV4 =
    iface:
    foxDenLib.util.removeIPCidr (
      lib.findFirst (ip: foxDenLib.util.isIPv4 ip && foxDenLib.util.isPrivateIP ip) "" iface.addresses
    );

  imageManifest = lib.importJSON "${pkgs.unifi-os-server-image}/manifest.json";
in
{
  # Based on:
  # - https://discourse.nixos.org/t/unifi-os-server-on-nixos/76039
  # - https://www.unihosted.com/blog/running-unifi-os-server-in-docker
  options.foxDen.services.unifi-os-server = {
  }
  // (foxDenLib.services.oci.mkOptions {
    svcName = name;
    name = "UniFi OS Server";
  });

  config = lib.mkIf svcConfig.enable (
    lib.mkMerge [
      (foxDenLib.services.oci.make {
        inherit
          pkgs
          config
          svcConfig
          name
          ;
        oci = {
          image = (lib.lists.head (lib.lists.head imageManifest).RepoTags);
          imageFile = pkgs.unifi-os-server-image;
          pull = "never";
          volumes = [
            "${stateDir}/persistent:/persistent"
            "${stateDir}/log:/var/log"
            "${stateDir}/data:/data"
            "${stateDir}/srv:/srv"
            "${stateDir}/unifi:/var/lib/unifi"
            "${stateDir}/mongodb:/var/lib/mongodb"
            "${mongoPreStartFix}:/etc/systemd/system/mongodb.service.d/prestart-fix.conf:ro"
            "${dbusStartFix}:/etc/dbus-1/system.d/start-fix.conf:ro"
            "${dbusStartFix}:/etc/dbus-1/session.d/start-fix.conf:ro"
          ];
          environment = {
            UOS_SYSTEM_IP = ifaceFirstV4 config.foxDen.hosts.hosts.${svcConfig.host}.interfaces.default;
            UOS_SERVER_VERSION = pkgs.unifi-os-server-image.version;
            FIRMWARE_PLATFORM = if pkgs.stdenv.hostPlatform.isAarch64 then "linux-arm64" else "linux-x64";
          };
          extraOptions = [
            "--systemd=always"
          ];
        };
        systemd = {
          preStart = lib.mkAfter ''
            ${pkgs.coreutils}/bin/mkdir -p ${stateDir}/{persistent,log,data,srv,unifi,mongodb,data/unifi-core/config/http,log/nginx,log/mongodb}

            # The Java UniFi controller requires exactly UUID v5 (SHA-1 name-based).
            # Generate a stable v5 UUID derived from the machine-id.
            uuid_file="${stateDir}/data/uos_uuid"
            if [ ! -f "$uuid_file" ]; then
              ${pkgs.util-linux}/bin/uuidgen -s -n @dns -N "$(${pkgs.coreutils}/bin/cat /etc/machine-id)" > "$uuid_file"
            fi
          '';
          serviceConfig = {
            ExecStartPre = [
              "+${(pkgs.writeShellScript "setup-cgroup.sh" ''
                cgroup="$(cat /proc/self/cgroup | ${pkgs.coreutils}/bin/cut -d: -f3 | head -1)"
                ${pkgs.coreutils}/bin/chown -R ${user.name}:${user.group} "/sys/fs/cgroup/$cgroup"
              '')}"
            ];
          };
        };
      }).config
      {
        foxDen.hosts.hosts.${svcConfig.host}.interfaces.default.nameOverride = "eth0";
      }
    ]
  );
}

Also note the absence of network configuration. Re-use whatever you used previously if you adapt my config. My wrappers around networking are quite “thick” and basically construct a whole NetNS for every service already, and the OCI wrapper configures it appropriately.