Installation & Deployment

This guide covers building and deploying the OneIoT UI Portals — the Angular Support Portal and Management Portal single-page applications, served by Nginx from a single Docker container.

The image definition (docker/Dockerfile), Nginx template (docker/ops/nginx.conf), container entrypoint (docker/ops/entrypoint.sh), local development Compose example (docker/docker-compose.yml) and CI pipeline (.github/workflows/build-callable.yml) are checked into this repository. For the server deployment in this guide, use the dedicated compose.portals.yaml and the shared .env from FT_DISK, as described in Download Deployment Files.

1. Overview

The portals are a presentation layer only. All data operations go to exactly one service: the UI Backend REST API. Everything else (database, Hazelcast, ClickHouse, FTACS) is reached through the backend. This guide therefore deploys the portals container and nothing else — the backend and all infrastructure are treated as pre-existing prerequisites and are deployed by their own guides.

Unlike earlier releases, the browser does not call the backend directly: Nginx inside this container reverse-proxies /iot-webservice/ (and /ai-agent/, /grafana-ro/) to the upstream services, and the entrypoint patches the Angular runtime config to the relative path /iot-webservice/. One consequence: the upstream URLs are container-side settings and only have to be reachable from the container — not from end-user browsers.

The container image bundles:

  • Support Portal — end-user support portal (Angular project support-center, built to dist/support-center, served at /support-portal/)

  • Management Portal — administrator portal (Angular project management-console, built to dist/management-console, served at /management-portal/)

  • Nginx — serves both apps, terminates HTTP/HTTPS and proxies the upstream APIs

  • an entrypoint script that renders nginx.conf from its template, patches the Angular runtime config, uploads portal permissions to the backend, then starts Nginx

The Angular project names (support-center, management-console) are internal build identifiers. They still appear in angular.json, in dist/ and in the in-container document root. The user-facing names and the served URLs are Support Portal (/support-portal/) and Management Portal (/management-portal/).

Item Value

Image

hub.friendly-tech.com/ui/portals:<tag> (dev on every push to main; pin an explicit tag for production)

Serves

Support Portal + Management Portal (Angular 19 SPAs)

Base image

nginx:alpine plus jq, bash, gettext (docker/Dockerfile)

Talks to

UI Backend REST API (required), AI Agent and Grafana (optional, proxied)

HTTP port

8880 → 80 (host → container)

HTTPS port

8843 → 443 (host → container), TLS terminated by Nginx

Two service URLs are exposed once the container is running:

2. Prerequisites

2.1. Host Requirements

The portals container is lightweight: static assets served by Nginx, no JVM and no local database. Size the host for the UI Backend and the infrastructure separately.

Component Minimum Recommended Notes

Docker Engine

24+

Latest stable

Required. The portals ship only as a container image.

Docker Compose

2.0

Latest stable

Used by every command in this guide.

RAM

256 MB

512 MB

Static assets and Nginx only.

Disk

1 GB

5 GB

Image plus Nginx access/error logs; size for the retained log volume.

2.2. Required External Dependencies

These services must be reachable from the container. None of them are deployed by this guide.

Component Why It Is Needed Required

UI Backend (hub.friendly-tech.com/ui/backend)

The target of every Angular API call, proxied by Nginx at /iot-webservice/, and the receiver of the portal permissions upload the entrypoint performs on startup. Without it the container fails to start — see Container Exits or Restarts Repeatedly.

Yes

AI Agent (ui-ai-agent)

Backs the in-app AI assistant, proxied at /ai-agent/. The assistant is enabled per installation through the backend setting AIServiceConnectionURL; when it is unset, the portals never call this path.

No

Grafana

Backs the embedded system-overview dashboards, proxied at /grafana-ro/ over HTTPS. The dashboard URLs come from backend settings; when they are unset, the portals never call this path.

No

The UI Backend in turn needs its own database (MySQL or Oracle), Hazelcast, FTACS and — for QoE data — ClickHouse. The portals reach none of them directly. Those dependencies, their versions and their ports belong to the UI Backend deployment guide.

2.3. Supported Operating Systems

Deployment Operating system

Docker

Linux (recommended), macOS, or Windows with WSL2

The published image is built for linux/amd64 — the default platforms input of .github/workflows/build-callable.yml. Another architecture requires a CI run with an explicit platforms value.

2.4. Registry Access

Network access to the hub.friendly-tech.com Docker registry, or offline image archives — see Offline Servers.

3. Network Requirements

Destination Port Protocol Purpose

Portals host

8880 / 8843 (host)

HTTP / HTTPS

End-user browsers load both portals; all API traffic is proxied through the same origin.

UI Backend

as configured in FT_UI_BACKEND_URL

HTTP / HTTPS

Nginx proxy for /iot-webservice/ and the entrypoint permissions upload. Container to backend only.

AI Agent

as configured in FT_AI_AGENT_URL

HTTP

Nginx proxy for /ai-agent/. Optional.

Grafana

as configured in FT_GRAFANA_API_URL

HTTPS

Nginx proxy for /grafana-ro/. Optional.

hub.friendly-tech.com

443

HTTPS

Pull the portals image, unless an offline archive is used.

Because the browser talks to the portals origin only, the upstream addresses do not have to be resolvable from client machines. FT_UI_BACKEND_URL may legitimately be a Docker service name such as http://ui-backend:8880/iot-webservice/ — provided the portals container and the backend share a Docker network.

3.1. Docker Networking

All upstream calls now originate in the container, so name resolution follows Docker’s rules:

  • A service name (http://ui-backend:8880/iot-webservice/, the entrypoint default) resolves only when both containers are on the same Docker network. Compose puts the services of one project on that project’s default network; a backend started by a different Compose project needs an explicit shared external network.

  • A routable host address (http://192.168.1.58:8881/iot-webservice/, as in docker/docker-compose.yml) always works and is the simplest choice for a portals-only host.

The /ai-agent/ and /grafana-ro/ locations resolve their upstreams at request time: the entrypoint reads the first nameserver from /etc/resolv.conf and injects it as the Nginx resolver, which works in both Docker (127.0.0.11) and Kubernetes (CoreDNS). A restart is therefore not needed when those upstream IP addresses change.

4. Registry Authentication

All portals images are pulled from hub.friendly-tech.com. Authenticate once per host before the first docker compose up.

docker login hub.friendly-tech.com

Enter the read-only pull credentials when prompted:

Field Value

Username

readonly

Password

fokxuw-fymte1-taSxyc

The readonly account provides pull-only access to the published images. It cannot push.

Alternatively, log in non-interactively:

echo "fokxuw-fymte1-taSxyc" | docker login hub.friendly-tech.com -u readonly --password-stdin

Verify authentication:

docker info | grep -A 5 Registry

4.1. Offline Servers

When the portals host cannot reach hub.friendly-tech.com, pull the image on a machine that does have registry access, export it to an archive, transfer it, and load it on the offline host. The only image for this deployment is hub.friendly-tech.com/ui/portals:<tag>.

Pass an explicit --platform matching the offline host’s architecture. Without it, docker pull selects the connected machine’s architecture — pulling on an Apple Silicon Mac produces an arm64 archive that fails with a platform does not match warning on an amd64 server. CI publishes linux/amd64 by default, so linux/arm64 is generally unavailable unless a build was run explicitly for it.

  1. On the offline host, find out which architecture it runs — this is the value you pass as PLATFORM below. Ask Docker itself, since it reports what the daemon will actually accept:

    docker version --format '{{.Server.Arch}}'

    If Docker is not installed there yet, use the operating system instead — uname -m on Linux, or echo $env:PROCESSOR_ARCHITECTURE in PowerShell on Windows. Map the result:

    docker version reports uname -m / Windows reports Use as PLATFORM

    amd64

    x86_64 / AMD64

    linux/amd64

    arm64

    aarch64 / ARM64

    linux/arm64

    On Windows with Docker Desktop, {{.Server.Arch}} reports the architecture of the Linux VM that actually runs the containers — which is the value you want, not the Windows host’s own architecture.

  2. On a machine with registry access, log in. Run this on its own — it prompts for a password, so anything pasted after it on the same go would be swallowed as input:

    docker login hub.friendly-tech.com
  3. Pull and export the image. Paste the whole block as-is; the only lines to change are PLATFORM and TAG on top.

    Linux / macOS (bash):

    PLATFORM=linux/amd64          # <-- from step 1
    TAG=<tag>                     # <-- your pinned tag
    
    docker pull --platform "$PLATFORM" "hub.friendly-tech.com/ui/portals:$TAG"
    docker save "hub.friendly-tech.com/ui/portals:$TAG" | gzip > "portals-$TAG.tar.gz"

    Windows (PowerShell):

    $PLATFORM = "linux/amd64"     # <-- from step 1
    $TAG      = "<tag>"           # <-- your pinned tag
    
    docker pull --platform $PLATFORM "hub.friendly-tech.com/ui/portals:$TAG"
    docker save -o "portals-$TAG.tar" "hub.friendly-tech.com/ui/portals:$TAG"

    On Windows, always write the archive with docker save -o <file>. Piping or redirecting docker save from PowerShell (docker save …​ > file.tar) corrupts the archive, because the PowerShell pipeline re-encodes the stream as text instead of passing raw bytes. docker load then fails with unexpected EOF or invalid tar header. To compress for transfer, use the bundled tar.exe (Windows 10 1803+ / Server 2019+): tar.exe -czf portals.tar.gz portals-$TAG.tar.

  4. Transfer the archive to the offline host via scp or removable media, together with compose.portals.yaml, .env, portals/.env (see Download Deployment Files) and the certificate and key in portals/ssl/.

  5. On the offline host, load the archive and start the service. Set TAG to the same value you used above:

    Linux / macOS (bash):

    TAG=<tag>
    
    gzip -dc "portals-$TAG.tar.gz" | docker load
    docker compose -f compose.portals.yaml up -d portals

    Windows (PowerShell):

    $TAG = "<tag>"
    
    docker load -i "portals-$TAG.tar"
    docker compose -f compose.portals.yaml up -d portals

Confirm the image is present before starting, so a missing or mis-architected image fails here rather than mid-startup:

docker images hub.friendly-tech.com/ui/portals

Upgrades use the same flow: pull the new tag on the connected machine, transfer and load the archive, then docker compose -f compose.portals.yaml up -d portals. See Stack Management.

5. Preparation

5.1. Directory Structure

The portals need a TLS certificate directory and, optionally, a log directory on the host. They use the common installation root /usr/local/ft-system (DATA_FOLDER), with this service’s files under portals/ — the Compose file mounts portals/ssl and portals/nginx/logs from there.

/usr/local/ft-system may require sudo on most Linux systems. Alternatively use a path in your home directory (e.g. ~/ft-system) and adjust all paths accordingly.

mkdir -p /usr/local/ft-system/portals/{ssl,nginx/logs}
cd /usr/local/ft-system

Every volume in compose.portals.yaml is written as ${DATA_FOLDER:-.}/…​. Compose resolves a relative path against the directory that holds the Compose file, so with DATA_FOLDER unset the mounts still land under /usr/local/ft-system as long as compose.portals.yaml sits there. To pin the path independently of where the Compose file lives, keep DATA_FOLDER=/usr/local/ft-system in the root .env next to it — Compose reads that file for variable interpolation. Setting DATA_FOLDER in the shell is not required, and relying on it is fragile: the value is lost in the next login shell.

5.1.1. Download Deployment Files

Download the deployment files from FT_DISK and keep their file names. Every Compose file refers to .env and ./<service>/.env relative to its own location, so it goes into /usr/local/ft-system/ next to the shared .env, never into a service subdirectory, and is passed to Compose with -f (for example docker compose -f compose.portals.yaml up -d).

  • FT_DISK — ONE_IOT_UI/docker/ui-portals — the dedicated Docker Compose stack of the UI Portals:

    • compose.portals.yaml → /usr/local/ft-system/compose.portals.yaml

    • .env (per-service layer) → /usr/local/ft-system/portals/.env (the FT_DISK folder is ui-portals, the host directory is portals)

  • FT_DISK — ONE_IOT_UI/docker — the shared layer:

    • .env (shared layer) → /usr/local/ft-system/.env

    • compose.all-in-one.yaml → /usr/local/ft-system/compose.all-in-one.yaml — only when all OneIoT UI services (UI Backend, portals, FT Device Network Service, AI Agent with its PostgreSQL) run on this one host. It replaces the dedicated Compose files; the per-service files still come from each service’s own FT_DISK folder.

  • Full-system deployment only — FT System MySQL Stack or FT System Oracle Stack: compose.yml and .env of the complete platform, including the databases and the infrastructure. This guide does not use them; see All in one server deployment.

5.1.2. Directory Layout

/usr/local/ft-system/                       # DATA_FOLDER
├── compose.portals.yaml                    # FT_DISK: ONE_IOT_UI/docker/ui-portals/
├── .env                                    # FT_DISK: ONE_IOT_UI/docker/  (shared layer)
└── portals/
    ├── .env                                # from FT_DISK ui-portals/.env
    ├── ssl/                                # -> /etc/nginx/ssl (read-only, only used when using HTTPS)
    │   ├── friendly.crt
    │   └── friendly.key
    ├── branding/                           # -> /branding (read-only; empty = built-in artwork)
    │   ├── favicon.ico                     #    each file optional on its own
    │   ├── logo.png
    │   ├── logo_alt.png
    │   └── login-bg-1920.png
    └── nginx/logs/                         # -> /var/log/nginx (optional)
Path Content Backup

compose.portals.yaml

The dedicated portals Compose file from FT_DISK — ONE_IOT_UI/docker/ui-portals. Its content is shown in Docker Compose.

Yes

.env

Shared stack environment from FT_DISK — ONE_IOT_UI/docker; it supplies every value Compose substitutes into compose.portals.yaml (see Environment Configuration).

Yes

portals/.env

Per-service environment from FT_DISK ONE_IOT_UI/docker/ui-portals, read only by the portals container at runtime.

Yes

portals/ssl/

PEM certificate and key, mounted read-only at /etc/nginx/ssl (see TLS Certificate (required)). Required — Nginx does not start without them.

Yes

portals/branding/

Per-installation branding images, mounted read-only at /branding. Always part of the layout; leave it empty and both portals keep their built-in artwork silently. Any of the four supported files that is present replaces the matching built-in default in both portals; once at least one is present, the absent ones log a WARN. See Branding Folder (Optional) and Configuration — Branding.

Yes

portals/nginx/logs/

Nginx access.log and error.log, mounted at /var/log/nginx. Optional: without the mount both streams stay on the container’s stdout/stderr.

No

5.2. Environment Configuration

The platform uses a two-layer environment file architecture, and the portals follow it. The portals hold no database configuration at all.

File Purpose

.env

Shared stack environment at the install root: every value Compose substitutes into the Compose file, plus values shared by services such as the internal keyword. In a full-stack install this is also where the database connection, ClickHouse, Hazelcast, JWT and COMPOSE_PROFILES live; the portals read only the values relevant to them.

portals/.env

Per-service environment: values read only by the portals entrypoint and Nginx at runtime.

Use the exact file sources and destination paths in Download Deployment Files.

Root .env — values Compose interpolates before the container starts and values shared with other services:

Variable Description Default Required

FT_UI_HTTP_PORT

Host port mapped by Compose to the container’s HTTP port (80).

8880

No

FT_UI_HTTPS_PORT

Host port mapped by Compose to the container’s HTTPS port (443).

8843

No

FT_UI_BACKEND_KEYWORD

Internal service-to-service keyword sent as X-Internal-Keyword when uploading permissions. Must match the backend’s configured value. When empty, the upload is attempted without the header and the backend normally rejects it.

empty (development example ft12Internal#%^)

Yes

DATA_FOLDER

Host base directory used by the ${DATA_FOLDER:-.}/portals/…​ volume mounts.

/usr/local/ft-system

Yes

TZ

Container timezone. Shared by all services; the portals receive it through the root .env.

Europe/Kyiv

No

FT_UI_HTTP_PORT=8880
FT_UI_HTTPS_PORT=8843
FT_UI_BACKEND_KEYWORD=ft12Internal#%^
DATA_FOLDER=/usr/local/ft-system
TZ=Europe/Kyiv

Keep the other variables in the downloaded shared .env; the complete file intentionally contains values that portals do not use. Do not repeat any of the variables above in portals/.env: that file is loaded after the root .env, so a copy there overrides the shared value, and Compose never reads it when it substitutes the published ports.

Copy FT_UI_BACKEND_KEYWORD verbatim — it must be byte-identical to the value configured for the UI Backend. A mismatch makes the startup permissions upload fail, which stops the container (see Container Exits or Restarts Repeatedly). Write it with no quotes and no trailing inline comment — an unquoted # following whitespace starts a comment and would cut the value short. The # inside the value is safe: it is not preceded by whitespace.

portals/.env — values read only by docker/ops/entrypoint.sh and Nginx:

Variable Description Default Required

FT_UI_BACKEND_URL

Full base URL of the UI Backend REST API, including the trailing /iot-webservice/. Nginx proxies /iot-webservice/ here, and the entrypoint appends iotw/Setting/userGroup/permissions to it for the permissions upload. Must be reachable from the container.

http://ui-backend:8880/iot-webservice/

Yes in practice — the default only works when a container named ui-backend shares the network

FT_AI_AGENT_URL

Upstream for the /ai-agent/ proxy (the in-app AI assistant). Scheme, host and port only, without a path: Nginx appends the full request URI, which already starts with /ai-agent/.

http://ui-ai-agent:8080

No

FT_GRAFANA_API_URL

Upstream for the /grafana-ro/ proxy (embedded dashboards). The entrypoint extracts the upstream host from this URL, and the proxy always calls it over HTTPS.

http://grafana:3000

No

METRICS_ALLOW_IP

Extra CIDR allowed to read /nginx_status, in addition to 127.0.0.1, 10.0.0.0/8 and 172.16.0.0/12.

0.0.0.0/0

No

FT_COMPANY_NAME

Company name patched into .company in both portals' assets/app-config.json at startup. Shown in the About dialog, the sidebar header and the login / restore-password / change-password screens. See Configuration — Branding.

Friendly Technologies — byte-identical to the value already shipped in the image, so leaving it unset reproduces current behavior exactly

No

FT_BRANDING_DIR

Path inside the container to the read-only mounted folder holding branding images (favicon and logos). Rarely needed: the default already matches the mount in compose.portals.yaml, and the host side is chosen there. An empty, absent or non-directory path is never an error — the portals just keep their built-in artwork. See Branding Folder (Optional) for what to put there and Configuration — Branding for the fallback behavior.

/branding

No

FT_UI_BACKEND_URL=http://ui-backend:8880/iot-webservice/
FT_AI_AGENT_URL=http://ui-ai-agent:8080
FT_GRAFANA_API_URL=http://grafana:3000
METRICS_ALLOW_IP=10.0.0.0/8
FT_COMPANY_NAME=Acme Communications

FT_BRANDING_DIR is deliberately left out of this baseline example. Its default (/branding) already matches Branding (Optional)'s volume mount, so the variable is only needed when the container path itself has to differ — which it normally does not. The host side of that mount is chosen in the compose file, not here.

METRICS_ALLOW_IP defaults to 0.0.0.0/0, which exposes /nginx_status to every client that can reach the portals port. Set it to the monitoring network’s CIDR in any environment where the portals port is not already firewalled.

5.3. Branding Folder (Optional)

To brand the portals for a specific customer, create the branding directory next to the other portals/ host paths and populate it with any of the four supported files:

mkdir -p /usr/local/ft-system/portals/branding
cp favicon.ico logo.png logo_alt.png login-bg-1920.png /usr/local/ft-system/portals/branding/

Mount it read-only at the path named by FT_BRANDING_DIR (default /branding) — see Branding (Optional). The full file list, the per-file fallback behavior and the operator warnings live in Configuration — Branding; this section covers only where the folder lives on the host.

Ready-made asset sets for existing customers are already checked in and can be copied as a starting point:

projects/customization-for-customers/radisys/
projects/customization-for-customers/genix/

5.4. TLS Certificate (required)

docker/ops/nginx.conf declares listen 443 ssl unconditionally and reads the certificate from fixed paths:

/etc/nginx/ssl/friendly.crt
/etc/nginx/ssl/friendly.key

Those files are not baked into the image — they come from the mounted portals/ssl directory. Nginx refuses to start when either is missing, so the mount is required even when only HTTP is published. Protocols are restricted to TLS 1.2/1.3 with ECDHE-GCM ciphers.

When Portals, ft-configs-ui and provision-portal run on the same host, you may instead use Optional Shared TLS Certificate for Frontends to share a customer certificate/key through local Compose file mounts. Portals and Provision still use friendly.crt / friendly.key; Configs still uses tls.crt / tls.key. The portals/ssl/ instructions below and existing defaults remain valid. This option does not change image TLS policies or configure Keycloak, SAML certificates, backend HTTPS keystores or backend truststores. Separate hosts need their files provisioned separately.

For a self-signed certificate (development and internal installations), use the bundled generator. It writes ssl/friendly.crt and ssl/friendly.key relative to the current directory, valid for ten years, with SANs for friendly-tech.com, *.friendly-tech.com, localhost and 127.0.0.1:

cd /usr/local/ft-system/portals
bash <path-to-repo>/docker/generate-cert.sh

For a CA-issued certificate, copy the PEM files under the expected names:

cp your-cert.crt /usr/local/ft-system/portals/ssl/friendly.crt
cp your-cert.key /usr/local/ft-system/portals/ssl/friendly.key
chmod 600 /usr/local/ft-system/portals/ssl/friendly.key

Write the literal install root, not ${DATA_FOLDER}. That variable is set in the .env file next to the Compose file, which Compose reads and your shell does not — in an interactive shell it expands to nothing, and the copy silently lands in /portals/ssl/ at the filesystem root.

The self-signed certificate and key checked into docker/ssl/ are development material only. Never deploy them to a customer-facing environment.

6. Deployment

6.1. Startup Dependencies

The entrypoint uploads permissions to the backend as soon as the container starts, using curl -f under set -euo pipefail. If the backend is unreachable or rejects the upload, the entrypoint exits and Nginx never starts. Wait for the backend before bringing the portals up:

cd /usr/local/ft-system
BACKEND_HOST=192.168.1.100          # the host behind FT_UI_BACKEND_URL

until curl -sf "http://${BACKEND_HOST}:8881/iot-webservice/swagger-ui/index.html" >/dev/null; do
  echo "Waiting for UI Backend at ${BACKEND_HOST} ..."
  sleep 5
done
echo "Backend is up -- starting portals"

docker compose -f compose.portals.yaml up -d portals

With restart: unless-stopped the container retries on its own, so a backend that comes up shortly afterwards resolves the situation without intervention — the wait loop simply avoids the noisy restart cycle.

6.2. Docker Compose

compose.portals.yaml is the dedicated Compose file from FT_DISK (see Download Deployment Files). Run it from /usr/local/ft-system with docker compose -f compose.portals.yaml. It joins the external ft-network, which has to exist before the first start (docker network create ft-network, once per host). The file as shipped:

version: "3.8"
name: ft-ui-portals

# UI Portals (Support Center & Management Console -- Angular / Nginx) as a
# STANDALONE stack, separate from the UI Backend.
#
# The UI Backend is a REQUIRED dependency: the portals register with it on
# startup and their Nginx proxies every REST call to it. It is NOT deployed here --
# set its address in portals/.env (FT_UI_BACKEND_URL) and make sure it is
# running and reachable first.
#
# Why a separate stack: run several portal instances (each its own copy of this
# compose, or several hosts) behind a load balancer to spread browser-facing
# load across many users, independently of a single backend.
#
# The stack joins the external "ft-network" so it can reach a co-located backend
# by container name; across hosts set FT_UI_BACKEND_URL in portals/.env to its host:port.
# Create the network once per host before the first start:
#   docker network create ft-network

services:
  portals:
    image: hub.friendly-tech.com/ui/portals:latest   # <-- pin a released tag
    container_name: portals
    ports:
      - "${FT_UI_HTTP_PORT:-8880}:80"
      - "${FT_UI_HTTPS_PORT:-8843}:443"
    env_file:
      - .env
      - ./portals/.env
    volumes:
      # TLS certificate + key the portals Nginx presents on HTTPS.
      - ${DATA_FOLDER:-.}/portals/ssl:/etc/nginx/ssl:ro
      - ${DATA_FOLDER:-.}/portals/nginx/logs:/var/log/nginx
    extra_hosts:
      - "host.docker.internal:host-gateway"
    restart: unless-stopped
    healthcheck:
      test: [ "CMD", "curl", "-f", "http://127.0.0.1/support-portal/index.html" ]
      interval: 10s
      timeout: 5s
      retries: 5
    stop_grace_period: 45s
    networks:
      - ft-network

networks:
  ft-network:
    external: true

What the entrypoint does. On start, docker/ops/entrypoint.sh performs five steps before Nginx serves traffic:

  1. Render the Nginx configuration. The entrypoint derives the backend and Grafana upstream hosts, then envsubst expands them together with FT_UI_BACKEND_URL, FT_AI_AGENT_URL, FT_GRAFANA_API_URL, METRICS_ALLOW_IP and the DNS resolver (the first nameserver in /etc/resolv.conf) into /etc/nginx/nginx.conf.template, writing /etc/nginx/nginx.conf.

  2. Patch the Angular runtime config. jq sets .server.api to the relative path /iot-webservice/ and .company to FT_COMPANY_NAME in both apps, so the browser calls the portals origin and Nginx proxies onward:

    /usr/share/nginx/html/support-center/browser/assets/app-config.json
    /usr/share/nginx/html/management-console/browser/assets/app-config.json
      -> .server.api = "/iot-webservice/", .company = "<FT_COMPANY_NAME>"
  3. Apply branding overrides. Any of the four files present under FT_BRANDING_DIR replaces the matching built-in asset in both apps; a missing file keeps its default and logs a WARN. See Configuration — Branding for the full file list and fallback behavior.

  4. Upload portal permissions to the backend. One PUT per app to ${FT_UI_BACKEND_URL}iotw/Setting/userGroup/permissions, authenticated with the internal keyword header and tagged by client type (sc = Support Portal, mc = Management Portal):

    curl -f --request PUT "${FT_UI_BACKEND_URL}iotw/Setting/userGroup/permissions" \
      -H "X-Internal-Keyword: ${FT_UI_BACKEND_KEYWORD}" \
      -H "X-Internal-Client-Type: sc" \
      -F "config=@/usr/share/nginx/html/support-center/browser/assets/app-permissions-config.json;type=application/json"

    A missing permissions file logs WARN and is skipped; a failed request aborts the entrypoint.

  5. Start Nginx (exec nginx -g 'daemon off;').

6.3. Branding (Optional)

Branding is part of the ordinary compose file — there is no separate overlay to apply and no extra -f on the command line. Both settings are optional: leave them out and the container behaves exactly as before.

FT_COMPANY_NAME goes into portals/.env (see Environment Configuration):

FT_COMPANY_NAME=Your Company

The images come from portals/branding/ under DATA_FOLDER. compose.portals.yaml mounts it unconditionally, so there is nothing to switch on:

services:
  portals:
    volumes:
      - ${DATA_FOLDER:-.}/portals/branding:/branding:ro

Drop in the files you want to override and recreate the container; leave the folder empty and the portals keep their built-in artwork without a word:

cp <customer>/logo.png /usr/local/ft-system/portals/branding/
docker compose -f compose.portals.yaml up -d --force-recreate portals

A WARN for an absent file appears only once at least one supported file is in the folder — that is, once branding is visibly in use and something was forgotten.

See Branding Folder (Optional) for creating portals/branding/ on the host, and Configuration — Branding for what goes in the folder, the per-file fallback behavior, and the operator warnings that apply regardless of which deployment method is used.

6.4. Nginx Routes

docker/ops/nginx.conf defines a single server block listening on 80 and 443:

Location Behaviour

/support-portal/

alias to /usr/share/nginx/html/support-center/browser/ with SPA fallback to its index.html. /support-portal without the trailing slash returns 301. Caching is disabled (no-store).

/management-portal/

alias to /usr/share/nginx/html/management-console/browser/ with the same fallback, redirect and cache policy.

/iot-webservice/

proxy_pass to FT_UI_BACKEND_URL with X-Real-IP / X-Forwarded-* headers, WebSocket upgrade support and 300 s connect/send/read timeouts. client_max_body_size is 1000 M, so large firmware and configuration uploads pass through.

/ai-agent/

proxy_pass to FT_AI_AGENT_URL, resolved at request time. 200 s timeouts, WebSocket upgrade support.

/grafana-ro/

proxy_pass to the Grafana host derived from FT_GRAFANA_API_URL, using HTTPS with SNI on and upstream certificate verification off. 60 s timeouts.

/nginx_status

Nginx stub_status for the Prometheus nginx-exporter. Allowed for 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12 and METRICS_ALLOW_IP; everything else is denied.

7. Verification

7.1. Startup Log

Check the container reached a healthy state:

docker compose -f compose.portals.yaml ps

Expected:

NAME       STATUS     PORTS
portals    healthy    0.0.0.0:8880->80/tcp, 0.0.0.0:8843->443/tcp

Confirm the entrypoint rendered the configuration, patched the apps, applied branding and uploaded the permissions:

docker compose -f compose.portals.yaml logs portals | grep -iE "generated|patched|branding|upload|WARN"

Expected lines: nginx.conf generated with FT_UI_BACKEND_URL=…​; two Patched …​ lines that differ by portal path (the script logs the full path, not the basename — both apps' configs share the basename app-config.json, so only the full path tells the lines apart):

+

Patched /usr/share/nginx/html/support-center/browser/assets/app-config.json -> .server.api = /iot-webservice/, .company = <value>
Patched /usr/share/nginx/html/management-console/browser/assets/app-config.json -> .server.api = /iot-webservice/, .company = <value>

+ one branding: file=<name> portal=<portal> applied line per branding file present in the folder, per portal — two lines per file, one for Support Portal and one for Management Portal. An installation that does not brand (an empty or absent folder — this guide’s baseline) produces no WARN at all, just one informational branding: dir=…​ using built-in defaults line. A WARN: branding: …​ line appears only once the folder holds at least one supported file and others are missing — one line per missing file. See Configuration — Branding; and Upload done (sc). / Upload done (mc).

7.2. Endpoint Checks

curl -I http://<host>:8880/support-portal/
curl -I http://<host>:8880/management-portal/

# API reachability through the proxy (not a direct backend call)
curl -I http://<host>:8880/iot-webservice/

Expected: HTTP 200 for the portals, 301 for the no-slash form.

Confirm the runtime config was patched:

docker compose -f compose.portals.yaml exec portals \
  cat /usr/share/nginx/html/support-center/browser/assets/app-config.json | grep '"api"'

Expected value:

"api": "/iot-webservice/"

If FT_COMPANY_NAME or FT_BRANDING_DIR are in use, confirm they landed. The Patched …​ startup log line already shows the company value (see Startup Log); check the served config directly, and use the branding: file=…​ applied log lines to confirm which images came from the mounted folder rather than the built-in defaults:

docker compose exec portals \
  cat /usr/share/nginx/html/support-center/browser/assets/app-config.json | grep '"company"'
docker compose logs portals | grep -i "branding: file="

Expected: "company": "<the value you set>", and one branding: file=<name> portal=support-center applied / branding: file=<name> portal=management-console applied pair per file present in FT_BRANDING_DIR.

Finally, open both apps in a browser:

8. Port Reference

Ports are written as published → container. Across hosts you connect to the published port; inside the Docker bridge you connect to the container port.

8.1. HTTP / HTTPS

Port Protocol Purpose Exposure

8880 → 80

HTTP

Serves both portals and proxies /iot-webservice/, /ai-agent/, /grafana-ro/. Configurable via FT_UI_HTTP_PORT.

Public (browsers)

8843 → 443

HTTPS

The same origin over TLS. Configurable via FT_UI_HTTPS_PORT.

Public (browsers)

The portals listen on no other ports.

# Confirm the portal ports are listening on the host
netstat -tlnp | grep -E '8880|8843'

8.2. Outbound Connections

Destination Protocol Purpose

UI Backend (FT_UI_BACKEND_URL)

HTTP / HTTPS

Proxied API traffic and the entrypoint permissions upload.

AI Agent (FT_AI_AGENT_URL)

HTTP

Proxied /ai-agent/ traffic. Optional.

Grafana (FT_GRAFANA_API_URL)

HTTPS

Proxied /grafana-ro/ traffic. Optional.

hub.friendly-tech.com

HTTPS

Image pull (docker compose pull), unless the image is loaded from an offline archive.

8.3. HTTP Endpoints

Method Path Purpose Auth

GET

/support-portal/

Support Portal single-page application.

None (the app authenticates against the backend)

GET

/management-portal/

Management Portal single-page application.

None (the app authenticates against the backend)

any

/iot-webservice/*

Reverse proxy to the UI Backend.

Authorization: Bearer <token> issued by the backend

any

/ai-agent/*

Reverse proxy to the AI Agent.

As required by the AI Agent

GET

/nginx_status

Nginx stub_status metrics.

IP allowlist

PUT

/iot-webservice/iotw/Setting/userGroup/permissions (on the UI Backend)

Outbound: portal permissions upload performed by the entrypoint on start.

X-Internal-Keyword header

9. Stack Management

9.1. Logs

# Container (entrypoint + Nginx)
docker compose -f compose.portals.yaml logs -f portals

# Nginx access / error logs on the host, when /var/log/nginx is mounted.
# DATA_FOLDER is set in .env for Compose, not in your shell -- use the install root itself.
tail -f /usr/local/ft-system/portals/nginx/logs/access.log
tail -f /usr/local/ft-system/portals/nginx/logs/error.log

9.2. Start, Stop, Restart

docker compose -f compose.portals.yaml restart portals      # re-runs the entrypoint (re-renders config, re-uploads permissions)
docker compose -f compose.portals.yaml stop portals         # stop and preserve the container
docker compose -f compose.portals.yaml rm -f portals        # stop and remove the portals container
docker compose -f compose.portals.yaml up -d portals        # (re)create only the portals service
Always pass -f compose.portals.yaml. /usr/local/ft-system can hold the Compose files of several stacks, and docker compose down without -f acts on whatever default Compose file it finds there. Use the named commands above.
Restarting the portals is the way to apply a changed upstream URL or to re-upload permissions after changing FT_UI_BACKEND_KEYWORD.

9.3. Shell Access

docker compose -f compose.portals.yaml exec portals sh

# Inspect the rendered configuration and the served assets
docker compose -f compose.portals.yaml exec portals cat /etc/nginx/nginx.conf
docker compose -f compose.portals.yaml exec portals ls /usr/share/nginx/html
docker compose -f compose.portals.yaml exec portals cat /usr/share/nginx/html/support-center/browser/assets/app-config.json

9.4. Updating the Portals

# 1. Pin the new tag in compose.portals.yaml, then:
docker compose -f compose.portals.yaml pull portals
docker compose -f compose.portals.yaml up -d portals

For offline servers, replace docker compose pull with the archive transfer flow in Offline Servers, then run docker compose -f compose.portals.yaml up -d portals.

10. Production Checklist

  • Change FT_UI_BACKEND_KEYWORD from the development default and keep it in sync with the backend.

  • Set FT_UI_BACKEND_URL to an address reachable from the container, including the trailing /iot-webservice/.

  • Provide a CA-issued certificate as portals/ssl/friendly.crt + friendly.key; never ship the development pair from docker/ssl/.

  • Restrict METRICS_ALLOW_IP to the monitoring network instead of the 0.0.0.0/0 default.

  • Pin the image tag — never deploy :latest or :dev to production.

  • Verify the backend is healthy first — use the wait loop so the permissions upload succeeds.

  • Enable auto-restart — restart: unless-stopped is set in compose.portals.yaml.

  • Configure log rotation for portals/nginx/logs/ when the log volume is mounted.

  • Restrict key permissions — chmod 600 portals/ssl/friendly.key.

11. Troubleshooting

11.1. Container Exits or Restarts Repeatedly

Cause

The entrypoint aborted. Under set -euo pipefail a failed permissions upload (curl -f) stops startup, so an unreachable backend or a rejected keyword takes the container down. A missing certificate stops Nginx itself.

Fix
docker compose -f compose.portals.yaml logs portals | tail -50
  • curl: (7) Failed to connect — FT_UI_BACKEND_URL is wrong or the backend is not on the container’s network; see Docker Networking.

  • HTTP 401/403 — FT_UI_BACKEND_KEYWORD does not match the backend.

  • cannot load certificate "/etc/nginx/ssl/friendly.crt" — the portals/ssl mount is missing or empty; see TLS Certificate (required).

11.2. UI Loads but Shows Connection Errors or 404s

Cause

The /iot-webservice/ proxy cannot reach the backend, or .server.api was not patched.

Fix
docker compose -f compose.portals.yaml exec portals cat /usr/share/nginx/html/support-center/browser/assets/app-config.json | grep '"api"'
curl -I http://<host>:8880/iot-webservice/
docker compose -f compose.portals.yaml logs portals | grep -i "nginx.conf generated"
  • Expected .server.api: /iot-webservice/. A full URL means the image ran with an older entrypoint.

  • 502 / 504 from the proxy: the backend is down, or FT_UI_BACKEND_URL points at an address the container cannot resolve.

11.3. Users Cannot Access Expected Features

Cause

The startup permissions upload was skipped, so the backend still holds the previous permission set.

Fix
docker compose -f compose.portals.yaml logs portals | grep -i "upload"
  • WARN: permissions file …​ not found (skipping sc upload) — the image does not contain app-permissions-config.json; use a correct image.

  • No upload lines at all — the entrypoint aborted earlier; see Container Exits or Restarts Repeatedly.

  • Re-run the upload by restarting: docker compose -f compose.portals.yaml restart portals.

11.4. Blank Page or 404s on Assets

Cause

The applications were built with a --base-href that does not match the served path, or the image was built from a stale dist/.

Fix
  • Confirm the deployed index.html carries the right base: docker compose -f compose.portals.yaml exec portals grep '<base' /usr/share/nginx/html/support-center/browser/index.html — expected /support-portal/.

  • Rebuild following Building the Image — Local Build, keeping the --base-href values.

11.5. AI Assistant or Embedded Dashboards Do Not Load

Cause

The optional upstreams are unset, unreachable, or disabled in the backend settings.

Fix
  • The AI assistant appears only when the backend setting AIServiceConnectionURL is set and the user group has the AI permission; its request path is /ai-agent/.

  • Grafana dashboards use the URLs stored in backend settings and the /grafana-ro/ proxy, which always calls the upstream over HTTPS.

  • Check the rendered upstreams: docker compose -f compose.portals.yaml logs portals | grep "nginx.conf generated".

11.6. Port Already in Use on 8880 or 8843

Cause

Another process is bound to the host port.

Fix
sudo lsof -i :8880
# then either free the port or change FT_UI_HTTP_PORT / FT_UI_HTTPS_PORT in the root .env

11.7. HTTPS on 8843 Fails to Start

Cause

Missing or unreadable certificate/key. nginx.conf always declares listen 443 ssl, so this stops the whole container, not only HTTPS.

Fix
  • Confirm portals/ssl/friendly.crt and portals/ssl/friendly.key exist and are readable inside the container (the mount is read-only by design).

  • The file names are fixed by nginx.conf — a certificate named server.crt is not picked up; see TLS Certificate (required).

  • Check Nginx errors: docker compose -f compose.portals.yaml logs portals | grep -i ssl, or portals/nginx/logs/error.log when the log volume is mounted.

11.8. Getting Support

Collect the following before contacting Friendly Tech support:

  1. compose.portals.yaml, .env (mask FT_UI_BACKEND_KEYWORD) and portals/.env

  2. docker compose -f compose.portals.yaml logs portals (full)

  3. portals/nginx/logs/error.log (last 200 lines), when mounted

  4. The image tag in use (docker compose -f compose.portals.yaml images)

  5. Output of the .server.api check in Endpoint Checks

  • Running the apps from source, without the container — see Contributing.

  • Configuration — the app-config.json runtime contract patched by the entrypoint.

  • API Integration — how the apps call /iot-webservice/.

  • Architecture — where the portals sit relative to the UI Backend and the platform services.

  • Observability — healthcheck, logs and /nginx_status.