Generated via ChatGPT
Generated via ChatGPT

The Problem Link to heading

UniFi OS Server (the controller software that runs on UniFi OS devices like Cloud Gateways, Dream Machines, etc.) works perfectly when accessed directly at https://<vm-ip>:11443. But put it behind a reverse proxy and you’ll hit a wall: the page loads partially, WebSocket connections get refused, and the UI hangs indefinitely.

This is a known issue with UniFi OS—it’s picky about Host and Origin headers, and its WebSocket handling doesn’t play nice with standard reverse proxy configurations.


The Architecture Link to heading

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────────┐
│   Internet      │────▶│  Nginx (NixOS)   │────▶│  UniFi OS VM        │
│  uos.domain.tld │     │  TLS termination │     │  Debian 13 + Podman │
│                 │     │  Reverse proxy   │     │  192.168.1.250:11443│
└─────────────────┘     └──────────────────┘     └─────────────────────┘

Host (server): NixOS server running Nginx with ACME certificates VM (uos): Debian 13 (Trixie) running on libvirt/QEMU with a ZFS zvol, UniFi OS installed via official installer using Podman


VM Setup: Debian 13 on libvirt with Cloud-Init Link to heading

Storage: ZFS zvol Link to heading

zvolDisk = "/dev/zvol/zpool1/uos-disk";

A dedicated ZFS zvol gives us snapshots, compression, and easy backups.

Cloud-Init Configuration Link to heading

user-data (configs/parts/uos/user-data):

#cloud-config
hostname: uos
users:
  - name: username
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-ed25519 SOME_KEY username@laptop
      - ssh-ed25519 SOME_KEY username@server
chpasswd:
  list: |
    root:disabled
  expire: False
ssh_pwauth: false
disable_root: true
package_update: true
package_upgrade: true
manage_etc_hosts: true
packages:
  - qemu-guest-agent
  - openssh-server
runcmd:
  - [ systemctl, start, qemu-guest-agent ]
  - [ systemctl, start, ssh ]

network-config (configs/parts/uos/network-config):

version: 2
ethernets:
  eth0:
    match:
      name: "e*"
    set-name: eth0
    addresses: [192.168.1.250/24]
    gateway4: 192.168.0.1
    nameservers:
      addresses: [192.168.1.1]

meta-data (configs/parts/uos/meta-data):

instance-id: uos-3
local-hostname: uos

Systemd Services for VM Lifecycle Link to heading

The NixOS configuration orchestrates the VM with several systemd services:

  1. uos-fetch-image — Downloads Debian 13 generic cloud image (raw preferred, qcow2 fallback)
  2. uos-cloudinit-seed — Generates seed.iso from cloud-init files
  3. uos-seed-disk — Writes the Debian image to the ZFS zvol (first boot only)
  4. uos-define-domain — Creates/updates the libvirt domain with bridged networking on br0
  5. uos-reprovision — Emergency “nuke and rebuild” command

The VM gets a static IP (192.168.1.250) on the bridged network, making it directly addressable from the Nginx host.


UniFi OS Installation Inside the VM Link to heading

Once the Debian VM is running, UniFi OS is installed using the official installer:

# Inside the VM
curl -fsSL https://get.unifi.ui.com/unifi-os/install.sh | bash

This installs UniFi OS via Podman. The service listens on port 11443 with a self-signed certificate.


The Nginx Reverse Proxy Configuration Link to heading

This is where the magic happens. The key insight: UniFi OS validates the Host and Origin headers against its own internal IP:port, not your public domain.

Complete NixOS Nginx Configuration Link to heading

# configs/parts/server-server-uos.nix

services.nginx.virtualHosts = lib.mkAfter {
  "uos.domain.tld" = {
    forceSSL = true;
    enableACME = false;
    sslCertificate = "${nginxCertDir}/domain.tld/fullchain.pem";
    sslCertificateKey = "${nginxCertDir}/domain.tld/key.pem";

    locations."/" = {
      proxyPass = "https://${vmIP}:11443";

      # Native NixOS WebSocket support - handles Upgrade/Connection headers correctly
      proxyWebsockets = true;

      # Disable global recommendedProxySettings to avoid header conflicts
      recommendedProxySettings = false;

      extraConfig = ''
        # UniFi OS Server backend
        proxy_ssl_verify off;

        # CRITICAL: Match the upstream Host: IP:11443
        # UniFi OS expects to see its own IP:port in the Host header
        proxy_set_header Host $proxy_host;

        # CRITICAL: Blank Origin and Referer
        # UniFi OS rejects WebSocket connections when Origin doesn't match
        proxy_set_header Origin "";
        proxy_set_header Referer "";

        # Preserve client information for logging/analytics
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;

        # Long-lived connections for WebSocket
        proxy_connect_timeout 60s;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;

        proxy_buffering off;
      '';
    };
  };
};

Why Each Setting Matters Link to heading

SettingPurpose
proxyWebsockets = trueNative NixOS WebSocket support—generates correct Upgrade/Connection headers only when needed
recommendedProxySettings = falsePrevents Nginx from setting Host $host globally, which would conflict with our location-specific override
proxy_ssl_verify offUpstream uses self-signed cert on internal IP; SNI verification would fail
proxy_set_header Host $proxy_hostThe fix: Sends 192.168.1.250:11443 instead of uos.domain.tld
proxy_set_header Origin ""The fix: Removes Origin header that causes WebSocket rejection
proxy_set_header Referer ""Additional compatibility—some UniFi versions check Referer too
X-Forwarded-* headersPreserve client IP, protocol, and original host for logging
proxy_buffering offRequired for WebSocket and long-polling
Extended timeoutsUniFi OS WebSocket connections are long-lived

The Journey: What Didn’t Work Link to heading

Attempt 1: Standard Reverse Proxy Link to heading

proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Result: Page loads partially, WebSocket connection refused.

Attempt 2: WebSocket Headers Only Link to heading

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Result: Still refused—UniFi OS was validating Origin against public domain.

Attempt 3: Blank Origin for WebSocket Only (via map) Link to heading

map $http_upgrade $uos_origin {
  default       $http_origin;
  ~*^websocket$ "";
}
proxy_set_header Origin $uos_origin;

Result: Better, but still intermittent issues.

Attempt 4: Blank Origin + Blank Referer + Correct Host (Current) Link to heading

proxy_set_header Host $proxy_host;
proxy_set_header Origin "";
proxy_set_header Referer "";

Result: Works reliably.


Security Considerations Link to heading

ConcernMitigation
proxy_ssl_verify offInternal network only; consider cert pinning if exposed
Blank Origin/RefererWeakens CSRF-like checks; acceptable for trusted internal service
Self-signed upstream certNormal for UniFi OS; TLS still encrypts traffic

Future improvement: If UniFi OS supports custom certificates, provision a proper cert for the internal IP and enable proxy_ssl_verify on with proxy_ssl_trusted_certificate.


Validation Commands Link to heading

# Format Nix code
nix fmt

# Evaluate configuration (dry-run)
nix build .#nixosConfigurations.server.config.system.build.toplevel

# Test apply (non-permanent, reverts on reboot)
sudo nixos-rebuild test --flake .#server

# Inspect generated Nginx config
sudo nginx -T | sed -n '/server_name uos\./,/}/p'

# Verify key headers in generated config
sudo nginx -T | grep -A2 'proxy_set_header Host'
sudo nginx -T | grep -A2 'proxy_set_header Origin'

# Permanent deploy (after verification)
sudo nixos-rebuild switch --flake .#server

Troubleshooting Checklist Link to heading

  • VM has static IP and is reachable from Nginx host: curl -k https://192.168.1.250:11443
  • Nginx can resolve/connect to VM IP
  • TLS certificates exist at ${nginxCertDir}/domain.tld/
  • proxyWebsockets = true is set on the location
  • recommendedProxySettings = false on the location (prevents Host header conflict)
  • proxy_set_header Host $proxy_host (not $host)
  • proxy_set_header Origin "" and proxy_set_header Referer ""
  • proxy_ssl_verify off for self-signed upstream
  • Timeouts sufficient for WebSocket (300s read/send)

Complete File Reference Link to heading

All configuration lives in the NixOS flake:

configs/
├── parts/
│   ├── server-server-uos.nix      # Main VM + Nginx config
│   ├── uos/
│   │   ├── user-data                # Cloud-init user config
│   │   ├── meta-data                # Cloud-init instance metadata
│   │   └── network-config           # Static network config
│   └── server-server-web.nix      # Global Nginx settings

Conclusion Link to heading

The key to running UniFi OS behind Nginx is understanding that it validates headers against its internal identity (IP:port), not your public domain. By sending Host $proxy_host and blanking Origin/Referer, you satisfy UniFi OS’s internal checks while still terminating TLS at your public edge.

This setup has been running reliably for months, handling WebSocket connections for real-time UI updates, device adoption, and live statistics without issues.


Running NixOS 26.05+ with flakes. Configuration managed in a private dotfiles repository.