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 todist/support-center, served at/support-portal/) -
Management Portal — administrator portal (Angular project
management-console, built todist/management-console, served at/management-portal/) -
Nginx — serves both apps, terminates HTTP/HTTPS and proxies the upstream APIs
-
an entrypoint script that renders
nginx.conffrom its template, patches the Angular runtime config, uploads portal permissions to the backend, then starts Nginx
|
The Angular project names ( |
| Item | Value |
|---|---|
Image |
|
Serves |
Support Portal + Management Portal (Angular 19 SPAs) |
Base image |
|
Talks to |
UI Backend REST API (required), AI Agent and Grafana (optional, proxied) |
HTTP port |
|
HTTPS port |
|
Two service URLs are exposed once the container is running:
-
Support Portal —
http://<host>:8880/support-portal/ -
Management Portal —
http://<host>:8880/management-portal/
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 ( |
The target of every Angular API call, proxied by Nginx at |
Yes |
AI Agent ( |
Backs the in-app AI assistant, proxied at |
No |
Grafana |
Backs the embedded system-overview dashboards, proxied at |
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 |
|
HTTP / HTTPS |
End-user browsers load both portals; all API traffic is proxied through the same origin. |
UI Backend |
as configured in |
HTTP / HTTPS |
Nginx proxy for |
AI Agent |
as configured in |
HTTP |
Nginx proxy for |
Grafana |
as configured in |
HTTPS |
Nginx proxy for |
|
|
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. |
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 indocker/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 |
|
Password |
|
|
The |
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 |
-
On the offline host, find out which architecture it runs — this is the value you pass as
PLATFORMbelow. 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 -mon Linux, orecho $env:PROCESSOR_ARCHITECTUREin PowerShell on Windows. Map the result:docker versionreportsuname -m/ Windows reportsUse as PLATFORMamd64x86_64/AMD64linux/amd64arm64aarch64/ARM64linux/arm64On 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. -
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 -
Pull and export the image. Paste the whole block as-is; the only lines to change are
PLATFORMandTAGon 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 redirectingdocker savefrom PowerShell (docker save … > file.tar) corrupts the archive, because the PowerShell pipeline re-encodes the stream as text instead of passing raw bytes.docker loadthen fails withunexpected EOForinvalid tar header. To compress for transfer, use the bundledtar.exe(Windows 10 1803+ / Server 2019+):tar.exe -czf portals.tar.gz portals-$TAG.tar. -
Transfer the archive to the offline host via
scpor removable media, together withcompose.portals.yaml,.env,portals/.env(see Download Deployment Files) and the certificate and key inportals/ssl/. -
On the offline host, load the archive and start the service. Set
TAGto 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 portalsWindows (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.
|
|
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 isui-portals, the host directory isportals)
-
-
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.ymland.envof 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 |
|---|---|---|
|
The dedicated portals Compose file from FT_DISK — ONE_IOT_UI/docker/ui-portals. Its content is shown in Docker Compose. |
Yes |
|
Shared stack environment from FT_DISK — ONE_IOT_UI/docker; it supplies every value Compose substitutes into |
Yes |
|
Per-service environment from FT_DISK |
Yes |
|
PEM certificate and key, mounted read-only at |
Yes |
|
Per-installation branding images, mounted read-only at |
Yes |
|
Nginx |
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 |
|---|---|
|
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 |
|
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 |
|---|---|---|---|
|
Host port mapped by Compose to the container’s HTTP port ( |
|
No |
|
Host port mapped by Compose to the container’s HTTPS port ( |
|
No |
|
Internal service-to-service keyword sent as |
empty (development example |
Yes |
|
Host base directory used by the |
|
Yes |
|
Container timezone. Shared by all services; the portals receive it through the root |
|
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 |
portals/.env — values read only by docker/ops/entrypoint.sh and Nginx:
| Variable | Description | Default | Required |
|---|---|---|---|
|
Full base URL of the UI Backend REST API, including the trailing |
Yes in practice — the default only works when a container named |
|
|
Upstream for the |
No |
|
|
Upstream for the |
No |
|
|
Extra CIDR allowed to read |
|
No |
|
Company name patched into |
|
No |
|
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 |
|
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
|
|
|
|
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 |
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 The self-signed certificate and key checked into |
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:
-
Render the Nginx configuration. The entrypoint derives the backend and Grafana upstream hosts, then
envsubstexpands them together withFT_UI_BACKEND_URL,FT_AI_AGENT_URL,FT_GRAFANA_API_URL,METRICS_ALLOW_IPand the DNS resolver (the firstnameserverin/etc/resolv.conf) into/etc/nginx/nginx.conf.template, writing/etc/nginx/nginx.conf. -
Patch the Angular runtime config.
jqsets.server.apito the relative path/iot-webservice/and.companytoFT_COMPANY_NAMEin 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>" -
Apply branding overrides. Any of the four files present under
FT_BRANDING_DIRreplaces the matching built-in asset in both apps; a missing file keeps its default and logs aWARN. See Configuration — Branding for the full file list and fallback behavior. -
Upload portal permissions to the backend. One
PUTper 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
WARNand is skipped; a failed request aborts the entrypoint. -
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
Nginx |
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:
-
Support Portal —
http://<host>:8880/support-portal/ -
Management Portal —
http://<host>:8880/management-portal/
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 |
|---|---|---|---|
|
HTTP |
Serves both portals and proxies |
Public (browsers) |
|
HTTPS |
The same origin over TLS. Configurable via |
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 ( |
HTTP / HTTPS |
Proxied API traffic and the entrypoint permissions upload. |
AI Agent ( |
HTTP |
Proxied |
Grafana ( |
HTTPS |
Proxied |
|
HTTPS |
Image pull ( |
8.3. HTTP Endpoints
| Method | Path | Purpose | Auth |
|---|---|---|---|
|
|
Support Portal single-page application. |
None (the app authenticates against the backend) |
|
|
Management Portal single-page application. |
None (the app authenticates against the backend) |
any |
|
Reverse proxy to the UI Backend. |
|
any |
|
Reverse proxy to the AI Agent. |
As required by the AI Agent |
|
|
Nginx |
IP allowlist |
|
|
Outbound: portal permissions upload performed by the entrypoint on start. |
|
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_KEYWORDfrom the development default and keep it in sync with the backend. -
Set
FT_UI_BACKEND_URLto 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 fromdocker/ssl/. -
Restrict
METRICS_ALLOW_IPto the monitoring network instead of the0.0.0.0/0default. -
Pin the image tag — never deploy
:latestor:devto production. -
Verify the backend is healthy first — use the wait loop so the permissions upload succeeds.
-
Enable auto-restart —
restart: unless-stoppedis set incompose.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 pipefaila 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_URLis wrong or the backend is not on the container’s network; see Docker Networking. -
HTTP
401/403—FT_UI_BACKEND_KEYWORDdoes not match the backend. -
cannot load certificate "/etc/nginx/ssl/friendly.crt"— theportals/sslmount 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.apiwas 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/504from the proxy: the backend is down, orFT_UI_BACKEND_URLpoints 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 containapp-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-hrefthat does not match the served path, or the image was built from a staledist/. - Fix
-
-
Confirm the deployed
index.htmlcarries 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-hrefvalues.
-
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
AIServiceConnectionURLis 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.confalways declareslisten 443 ssl, so this stops the whole container, not only HTTPS. - Fix
-
-
Confirm
portals/ssl/friendly.crtandportals/ssl/friendly.keyexist and are readable inside the container (the mount is read-only by design). -
The file names are fixed by
nginx.conf— a certificate namedserver.crtis not picked up; see TLS Certificate (required). -
Check Nginx errors:
docker compose -f compose.portals.yaml logs portals | grep -i ssl, orportals/nginx/logs/error.logwhen the log volume is mounted.
-
11.8. Getting Support
Collect the following before contacting Friendly Tech support:
-
compose.portals.yaml,.env(maskFT_UI_BACKEND_KEYWORD) andportals/.env -
docker compose -f compose.portals.yaml logs portals(full) -
portals/nginx/logs/error.log(last 200 lines), when mounted -
The image tag in use (
docker compose -f compose.portals.yaml images) -
Output of the
.server.apicheck in Endpoint Checks
12. Related Documentation
-
Running the apps from source, without the container — see Contributing.
-
Configuration — the
app-config.jsonruntime 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.