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

3 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.

I’m getting an error attribute ā€˜unifi-os-server-image’ missing when trying to use imageFile = pkgs.unifi-os-server-image; for my oci-containers.

I’m still pretty new to nix, so I’m sure I have something messed up when trying to combine yours and @brandonboo2024’s configs.

Module

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

  cfg = config.services.unifi-os-server;
  stateDir = "/var/lib/unifi-os";
  name = "unifi-os-server";
  imageManifest = lib.importJSON "${pkgs.unifi-os-server-image}/manifest.json";

  # 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)";
    svcName = name;
    name = "UniFi OS Server";

    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;
      oci-containers = {
        backend = "podman";
        containers.unifi-os-server = {
          image = (lib.lists.head (lib.lists.head imageManifest).RepoTags);
          imageFile = pkgs.unifi-os-server-image;
          autoStart = true;
          privileged = true;
          pull = "never";

          ports = [
            "4430: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;
        };

        # https://www.crosstalksolutions.com/complete-unifi-os-server-installation-on-linux-best-practices/
      };
    };
    networking.firewall = mkIf cfg.openFirewall {
      allowedTCPPorts = [
        4430 # 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

      '';
    };
  };
}

unifi-os-server-image derivation

I think it’s exactly what you have

{
  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 is probably your issue you need to include the derivation of the unifi-os-server-image into your nixpkgs, if you want to use pkgs.unifi-os-server-image. Currently it can’t find it. This can be done in several ways one of which is an overlay. If you are fine with relative paths in your modules you can call the package in a let in expression use it later:

let
uospackage = pkgs.callPackage ../../pkgs/unifi-os-server-image {
      sha256 = "sha256-IPoWR5GTiy7J1WgMEYdTxGo26qM2nO+U1c742pRo354=";
    };
in
{
# use usopackage
}

That was the issue, now I it’s throwing the error uosserver:0.0.54: image not known

your virtualization environment / manager / podman couldn’t find the unpacked / pulled image tag. I haven’t tried porting my setup. To the new way, yet. Have you tried to cleanly rebuild everything?

Not yet, no. I’ll have to give it a go using the original approach.

I’m away for the weekend so I’ll have to give it a go next week.

Even declaring the tag explicitly with imageTag = "uosserver:0.0.54"; and image = cfg.imageTag;, I get the unknown tag error. Weird.

So i updated my configuration with the automatic tag extraction Doridian suggested. Tested it on my raspberry pi i think everything still works. Here is the patch for:

diff --git a/hosts/rpi/configuration.nix b/hosts/rpi/configuration.nix
index 836c5e8..128ed37 100644
--- a/hosts/rpi/configuration.nix
+++ b/hosts/rpi/configuration.nix
@@ -44,7 +44,6 @@
           url = "https://fw-download.ubnt.com/data/unifi-os-server/df5b-linux-arm64-5.0.6-f35e944c-f4b6-4190-93a8-be61b96c58f4.6-arm64";
           sha256 = "sha256-aKCig6g1tSj+QHkarf1czVGOBRkHVmkjdX9sWy/rzQg=";
         };
-        imageTag = "uosserver:0.0.54";
         openFirewall = true;
       };
     };
diff --git a/modules/unifi/uos.nix b/modules/unifi/uos.nix
index debe8b3..3864db8 100644
--- a/modules/unifi/uos.nix
+++ b/modules/unifi/uos.nix
@@ -55,15 +55,6 @@
         '';
       };
 
-      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;
@@ -147,11 +138,12 @@
         '';
       };
 
-      virtualisation.oci-containers.containers.unifi-os-server = {
-        image = cfg.imageTag;
-        imageFile = pkgs.runCommand "unifi-os-image.tar" {} ''
-          ln -s ${cfg.package}/image.tar $out
-        '';
+      virtualisation.oci-containers.containers.unifi-os-server = let
+        imageManifest = lib.importJSON "${cfg.package}/manifest.json";
+      in {
+        image = lib.lists.head (lib.lists.head imageManifest).RepoTags;
+        imageFile = cfg.package;
+
         autoStart = true;
         privileged = true;
 
diff --git a/pkgs/unifi-os-server-image/default.nix b/pkgs/unifi-os-server-image/default.nix
index 15fa773..c1da642 100644
--- a/pkgs/unifi-os-server-image/default.nix
+++ b/pkgs/unifi-os-server-image/default.nix
@@ -48,7 +48,7 @@ stdenvNoCC.mkDerivation rec {
     fi
 
     mkdir -p "$out"
-    cp "$image_tar" "$out/image.tar"
+    tar -xf "$image_tar" -C "$out"
   '';
 
   meta = with lib; {

Podman is still not liking that tag for some reason. I noticed you’re using the arm64 version because of your pi whereas I’m using the x64 version. Other than the URL/hash differences, I can’t see what I’m doing differently.

journalctl -xeu podman-unifi-os-server

Error: uosserver:0.0.54: image not known
podman[732069]: 2026-04-21 15:34:38.752345441 -0500 CDT m=+0.012704092 image pull-error  uosserver:0.0.54 uosserver:0.0.54: image not known


configuration.nix

    services.unifi-os-server = {
      enable = true;
      openFirewall = true;
      package = pkgs.callPackage ../../pkgs/unifi-os-server-image { };
    };

unifi-os-server-image/default.nix

{
  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";
  hash = "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 hash;
  };

  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 ];
  };
}


unifi-os-server.nix

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

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

  # 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)";
    svcName = name;
    name = "UniFi OS Server";

    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 = "…"; }`
      '';
    };
    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;
      oci-containers = {
        backend = "podman";
        containers.unifi-os-server =
          let
            imageManifest = lib.importJSON "${cfg.package}/manifest.json";
          in
          {
            image = lib.lists.head (lib.lists.head imageManifest).RepoTags;
            imageFile = cfg.package;
            autoStart = true;
            privileged = true;
            pull = "never";

            ports = [
              "4430: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;
          };

        # https://www.crosstalksolutions.com/complete-unifi-os-server-installation-on-linux-best-practices/
      };
    };
    networking.firewall = mkIf cfg.openFirewall {
      allowedTCPPorts = [
        4430 # 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

      '';
    };
  };
}

I finally got around to investigating the beam.smp crashes. I had attempted first to use the OP’s nix file instead of mine, but still had the same issue.

I asked codex to look into it. This is what it found:

UniFi OS Server / RabbitMQ beam.smp crashes in Podman: raise the container PID limit

I ran into intermittent beam.smp crashes from the UniFi OS Server container. The root cause was not RabbitMQ data corruption or disk space. It was Podman’s default container PID/thread limit.

Symptoms

The affected process was beam.smp, which in this case belongs to RabbitMQ inside the UniFi OS Server container.

The useful logs were under:

/var/log/unifi-os/rabbitmq/

The RabbitMQ crash log repeatedly showed errors like:

Failed to create dirty io scheduler thread 3, error = 11
Aborted (core dumped)

and:

{eagain,[{erlang,open_port,[{spawn,"/bin/sh -s unix:cmd"}, ... ]}

RabbitMQ’s disk monitor was also repeatedly crashing:

rabbit_disk_monitor terminating
Reason for termination == {eagain, ...}

EAGAIN here was the important clue: Erlang was failing to create OS resources, specifically processes/threads.

How I confirmed it

First I identified that beam.smp was RabbitMQ inside the UniFi OS Server container:

sudo podman ps -a

Then I inspected the container’s PID limit:

sudo podman inspect unifi-os-server \
  --format '{{json .HostConfig.PidsLimit}} {{json .HostConfig.Ulimits}}'

That showed:

2048

Inside the container, I checked the cgroup PID accounting:

sudo podman exec unifi-os-server sh -c '
  echo pids.max
  cat /sys/fs/cgroup/pids.max
  echo pids.current
  cat /sys/fs/cgroup/pids.current
  echo pids.events
  cat /sys/fs/cgroup/pids.events
'

The result was:

pids.max
2048

pids.current
816

pids.events
max 38

The key line is max 38. That means the container had hit its PID/thread ceiling 38 times. RabbitMQ/Erlang was not out of disk; it was being denied new threads/processes by the container cgroup.

RabbitMQ’s Erlang VM was also configured with a large async thread pool:

beam.smp ... -A 224 ...

That is normal enough for Erlang/RabbitMQ, but UniFi OS Server runs a full process tree in one systemd-style container, so Podman’s default --pids-limit=2048 was too tight.

Fix

Raise the Podman PID limit for the UniFi OS Server container.

In my NixOS module, I added this to the container’s extraOptions:

virtualisation.oci-containers.containers.unifi-os-server = {
  extraOptions = [
    "--systemd=always"
    "--add-host=host.docker.internal:host-gateway"
    "--pids-limit=8192"
  ];
};

In the actual module I also left a comment explaining why:

# RabbitMQ's Erlang VM creates hundreds of scheduler/helper threads.
# Podman's default pids limit of 2048 is too tight for the full UniFi OS
# process tree and causes intermittent beam.smp EAGAIN crashes.
"--pids-limit=8192"

After applying the NixOS config and recreating/restarting the container, confirm the new limit:

sudo podman inspect unifi-os-server --format '{{json .HostConfig.PidsLimit}}'

Expected:

8192

or from inside the container:

sudo podman exec unifi-os-server cat /sys/fs/cgroup/pids.max

Expected:

8192

Takeaway

If beam.smp crashes in a Podman container with messages like:

Failed to create dirty io scheduler thread ..., error = 11

or Erlang reports eagain from open_port, check the container’s pids.max and pids.events.

For RabbitMQ/Erlang workloads, especially inside a larger systemd-style container, Podman’s default --pids-limit=2048 may be too low. Raising it with --pids-limit=8192 fixed the issue here.

Howdy folks, I saw the notification in the UniFi Controller to upgrade to UniFi OS Server and came across this thread. Thank you to everyone who posted their snippets!

I made a flake for this: GitHub - rcambrj/unifi-os-server: UniFi OS Server for Nix Ā· GitHub

It has the UniFi OS Server image as package for x86_64-linux & aarch64-linux; and the macOS Application for x86_64-darwin & aarch64-darwin. It also has a NixOS module for a systemd service on linux. It updates to the latest version from the UniFi website once per week. I’ve only tested x86_64-linux (NixOS service) and aarch64-darwin (Application package).

Pull requests, bug reports, criticisms all welcome :slight_smile: and thanks once again. Do you want to be credited? Just make a pull request.

One small thing: I didn’t put the fix in for Failed to create dirty io scheduler thread 3, error = 11 because I’m not seeing that error. Happy to accept a pull request though!

1 Like

This is why open source is best source! Appreciate the work you put into this and will give your flake a look/test!

1 Like

Thank you for the module. I started playing with it and hit a few errors when allowing remote management (the Incorrect Date/Time Settings):

  • Incorrectly synced time
    • Container clock being wrong
  • MongoDB conflicts
  • Controller IP set to wrong value
    • This makes migration harder and messes up the inform callback from the devices

Here are my fixed up snippets, I also added explicit DNS servers. The IP is now hard set, this is not the best solution, but makes the addoption work. Besides the IP, don’t forget to change your timezone (mine is Europe/Prague).

{ config, lib, pkgs, ... }:

let
  inherit (lib) mkEnableOption mkIf mkOption mkMerge types;

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

  # Capture unifi-core stdout/stderr to readable host files
  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 internal services expect but do not 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
  '';

  # UniFi Network manages its own embedded MongoDB instance on 127.0.0.1:27117.
  # The standalone systemd mongodb.service inside the image is redundant and causes a degraded loop.
  mongoNoop = pkgs.writeText "mongodb-noop.conf" ''
    [Service]
    Type=oneshot
    ExecStart=
    ExecStart=/bin/true
    RemainAfterExit=yes
  '';

  # UniFi Core checks timedatectl and explicitly blocks cloud/SSO logins if it reports unsynchronized.
  # Inside container namespaces, timedatectl usually fails. This script mocks a healthy host environment.
  fakeTimedatectl = pkgs.writeTextFile {
    name = "timedatectl";
    executable = true;
    text = ''
      #!/bin/sh
      if [ "$1" = "show" ]; then
        value_mode=0
        for arg in "$@"; do
          [ "$arg" = "--value" ] && value_mode=1
        done

        if [ "$value_mode" = 1 ]; then
          printed=0
          for arg in "$@"; do
            case "$arg" in
              --property=NTPSynchronized) echo yes; printed=1 ;;
              --property=NTP) echo yes; printed=1 ;;
            esac
          done
          [ "$printed" = 1 ] || echo yes
          exit 0
        fi
        echo "NTPSynchronized=yes"
        echo "NTP=yes"
        exit 0
      fi

      echo "               Local time: $(date)"
      echo "           Universal time: $(date -u)"
      echo "                 RTC time: n/a"
      echo "                Time zone: Europe/Prague"
      echo "System clock synchronized: yes"
      echo "              NTP service: active"
      echo "          RTC in local TZ: no"
      exit 0
    '';
  };
in {
  options.services.unifi-os-server = {
    enable = mkEnableOption "UniFi OS Server container using Podman";

    package = mkOption {
      type = types.package;
      description = "Package containing the extracted UniFi OS Server OCI archive image.tar.";
    };

    imageTag = mkOption {
      type = types.str;
      description = "Exact image name:tag embedded in image.tar.";
      example = "uosserver:0.0.54";
    };

    systemIp = mkOption {
      type = types.str;
      description = "LAN IP address that UniFi OS and the Network App should advertise.";
      example = "192.168.1.50";
    };

    openFirewall = mkOption {
      type = types.bool;
      default = false;
      description = "Whether to open the required UniFi ports in the NixOS firewall.";
    };

    disableStandaloneMongo = mkOption {
      type = types.bool;
      default = true;
      description = "Disable the standalone mongodb.service inside the container.";
    };

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

    extraVolumes = mkOption {
      type = types.listOf types.str;
      default = [];
      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";

    networking.firewall = mkIf cfg.openFirewall {
      allowedTCPPorts = [ 443 8080 8443 8843 8880 6789 ];
      allowedUDPPorts = [ 3478 10001 123 53 ];
    };

    systemd.services.podman-unifi-os-server = {
      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";
      };

      # Inject host configuration before Podman launches
      preStart = lib.mkAfter ''
        props="${stateDir}/unifi/system.properties"
        mkdir -p "$(dirname "$props")"
        touch "$props"
        ${pkgs.gnused}/bin/sed -i '/^system_ip=/d' "$props"
        echo "system_ip=${cfg.systemIp}" >> "$props"
      '';
    };

    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"
        "123:123/udp" "53:53/udp" "3478:3478/udp" "10001:10001/udp"
      ];

      environment = {
        UOS_SYSTEM_IP = cfg.systemIp;
        TZ = "Europe/Prague"; # Adjust to your timezone
        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"
        "${fakeTimedatectl}:/usr/bin/timedatectl:ro"
        "/etc/localtime:/etc/localtime:ro"
      ]
      ++ lib.optional cfg.disableStandaloneMongo "${mongoNoop}:/etc/systemd/system/mongodb.service.d/noop.conf:ro"
      ++ cfg.extraVolumes;

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

Nothing to change about image extraction.

{ pkgs, ... }:

{
  imports = [ ./modules/unifi-os-server.nix ];

  services.unifi-os-server = {
    enable = true;
    openFirewall = true;
    systemIp = "192.168.88.118"; # Set your host's local network IP
    imageTag = "uosserver:0.0.54";

    package = pkgs.callPackage ./pkgs/unifi-os-server-image.nix {
      sha256 = "sha256-IPoWR5GTiy7J1WgMEYdTxGo26qM2nO+U1c742pRo354=";
    };
  };
}
1 Like

You’ve got a couple PRs waiting for you when you get a chance.

@Esch thanks for the ping, I didn’t get a notification, I’ll look into why and check them!

I raised an issue, but it doesn’t seem to be updating past 5.0.8 for me. Are you experiencing the same?