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.

Everything described here is checked in to this repository: the image definition (docker/Dockerfile), the Nginx template (docker/ops/nginx.conf), the container entrypoint (docker/ops/entrypoint.sh), the Compose examples (docker/docker-compose.yml, docker/ui-only/docker-compose.yml) and the CI pipeline that builds and pushes the image (.github/workflows/build-callable.yml).

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

20.10

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.yml, .env, portals/.env 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 up -d portals

    Windows (PowerShell):

    $TAG = "<tag>"
    
    docker load -i "portals-$TAG.tar"
    docker compose 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 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. This follows the same ft-system base-directory convention as the rest of the platform.

/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 the Compose example is written as ${DATA_FOLDER:-.}/…​, so with DATA_FOLDER unset the mounts resolve against the current directory — which is why the cd above is what actually fixes the install root. Setting DATA_FOLDER in the shell is not required, and relying on it is fragile: the value is lost in the next login shell. To pin the path independently of the working directory, set DATA_FOLDER in the .env file next to compose.yml — Compose reads that file for variable interpolation.

5.1.1. Directory Layout

/usr/local/ft-system/
├── compose.yml                             # portals service definition
├── .env                                    # shared stack environment
└── portals/
    ├── .env                                # per-service environment
    ├── ssl/                                # -> /etc/nginx/ssl (read-only, REQUIRED)
    │   ├── friendly.crt
    │   └── friendly.key
    └── nginx/logs/                         # -> /var/log/nginx (optional)
Path Content Backup

compose.yml

Portals service definition (see Docker Compose).

Yes

.env

Shared stack environment — the values every service reads (see Environment Configuration).

Yes

portals/.env

Per-service environment — the portals host ports.

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/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: values common to every service — upstream addresses, the internal keyword, timezone, base data folder. In a full-stack install this is also where the database connection, ClickHouse, Hazelcast, JWT and COMPOSE_PROFILES live; the portals read none of those.

portals/.env

Per-service environment: the portals' own host ports.

Create both files:

touch /usr/local/ft-system/.env /usr/local/ft-system/portals/.env

Root .env — the variables the container itself reads (docker/ops/entrypoint.sh):

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_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

FT_AI_AGENT_URL

Upstream for the /ai-agent/ proxy (the in-app AI assistant).

http://ui-ai-agent:8080

No

FT_GRAFANA_API_URL

Upstream for the /grafana-ro/ proxy (embedded dashboards). The host part is extracted into FT_GRAFANA_DOMAIN, and the proxy always calls it over HTTPS.

http://grafana:3000

No

FT_GRAFANA_DOMAIN

Overrides the host extracted from FT_GRAFANA_API_URL.

derived from FT_GRAFANA_API_URL

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

TZ

Container timezone.

none (example Europe/Kiev)

No

DATA_FOLDER

Host base directory used by the volume mounts in compose.yml.

/usr/local/ft-system (this guide’s convention)

Yes

FT_UI_BACKEND_URL=http://ui-backend:8880/iot-webservice/
FT_UI_BACKEND_KEYWORD=ft12Internal%^
FT_AI_AGENT_URL=http://ui-ai-agent:8080
METRICS_ALLOW_IP=10.0.0.0/8
TZ=Europe/Kiev
DATA_FOLDER=/usr/local/ft-system

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.

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.

portals/.env — this service only:

Variable Description Default Required

FT_UI_HTTP_PORT

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

8880

No

FT_UI_HTTPS_PORT

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

8843

No

FT_UI_HTTP_PORT=8880
FT_UI_HTTPS_PORT=8843

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

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 compose.yml, 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 up -d

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

Create compose.yml at the install root (/usr/local/ft-system). This defines the portals service only — no database, Hazelcast, ClickHouse, or FTACS.

name: ft-ui-portals

services:
  portals:
    image: hub.friendly-tech.com/ui/portals:<tag>   # <-- pin your tag
    env_file:
      - .env                 # shared stack environment
      - ./portals/.env       # per-service environment
    ports:
      - "${FT_UI_HTTP_PORT:-8880}:80"      # HTTP  (host -> container)
      - "${FT_UI_HTTPS_PORT:-8843}:443"    # HTTPS (host -> container)
    volumes:
      - ${DATA_FOLDER:-.}/portals/ssl:/etc/nginx/ssl:ro      # required
      - ${DATA_FOLDER:-.}/portals/nginx/logs:/var/log/nginx  # optional
    healthcheck:
      test: [ "CMD", "curl", "-f", "http://127.0.0.1/support-portal/index.html" ]
      interval: 10s
      timeout: 5s
      retries: 50
    restart: unless-stopped

The repository ships two working variants of this file: docker/ui-only/docker-compose.yml (ports, volumes, healthcheck) and docker/docker-compose.yml (local build against a routable backend address).

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

  1. Render the Nginx configuration. envsubst expands FT_UI_BACKEND_URL, BACKEND_DOMAIN, FT_AI_AGENT_URL, FT_GRAFANA_API_URL, FT_GRAFANA_DOMAIN, 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/ 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/"
  3. 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.

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

6.3. 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 https://$FT_GRAFANA_DOMAIN$request_uri 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 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 and uploaded the permissions:

docker compose logs portals | grep -iE "generated|patched|upload|WARN"

Expected lines: nginx.conf generated with FT_UI_BACKEND_URL=…​, two Patched app-config.json → .server.api = /iot-webservice/ lines, 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 exec portals \
  cat /usr/share/nginx/html/support-center/browser/assets/app-config.json | grep '"api"'

Expected value:

"api": "/iot-webservice/"

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 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 restart portals      # re-runs the entrypoint (re-renders config, re-uploads permissions)
docker compose down                 # stop and remove the container
docker compose up -d                # (re)create
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 exec portals sh

# Inspect the rendered configuration and the served assets
docker compose exec portals cat /etc/nginx/nginx.conf
docker compose exec portals ls /usr/share/nginx/html
docker compose 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.yml, then:
docker compose pull
docker compose up -d

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

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

  • 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 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 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 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 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 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 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 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 portals/.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 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.yml, .env and portals/.env (mask FT_UI_BACKEND_KEYWORD)

  2. docker compose logs portals (full)

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

  4. The image tag in use (docker compose 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.