Monitoring Exporters and Grafana Installation Guide

Table of Contents

Version 1.6.9 | Updated: August 08, 2026

Monitoring Exporters and Grafana Installation Guide

Introduction

This guide provides step-by-step instructions for setting up monitoring exporters and Grafana for observability across various servers. The setup includes Node Exporter, Process Exporter, cAdvisor, MySQL Exporter, Oracle DB Exporter, ClickHouse (native Prometheus endpoint), PostgreSQL Exporter, Nginx Exporter, Windows Exporter, JMX Exporter for WildFly/ACS, and a Prometheus-Grafana-Alertmanager stack deployment. Hazelcast is covered two ways: embedded-in-ACS via its native Prometheus endpoint (:9101, full MBean set), standalone/docker via the bundled jmx-hazelcast exporter (JMX :9110 → :9101), because the native endpoint of a standalone Hazelcast omits isClusterSafe and the Metrics_* MBeans the dashboard needs. FT UI Services (Angular UI backend, AI Agent, Device Network Service, APIs) expose native Spring Boot Actuator or FastAPI metrics endpoints. Alertmanager handles alert notifications via Email, Telegram, Slack, MS Teams, Webhooks, SMS, and SNMP. Following this guide will ensure proper monitoring, alerting, and visualization of system metrics, database performance, application health, and web service performance.

Recent Updates (DEV-524 - FT UI Services Monitoring):

  • ✅ Nginx Exporter for Angular UI (portals container)

  • ✅ ClickHouse metrics via native Prometheus endpoint (port 9363)

  • ✅ PostgreSQL Exporter for AI agent database

  • ✅ Native metrics from: ft-device-network, ai-agent, ui-backend

  • ✅ Native metrics from APIs: provision-api, northbound-api, service-api

  • ✅ New Grafana dashboard: FT UI Services (comprehensive monitoring for all components)

System Requirements

Minimum per exporter node:

  • OS: Ubuntu 20.04+, Debian 11+, CentOS 7+, Rocky Linux 8+

  • RAM: 2GB (4GB recommended)

  • Disk: 10GB free space

  • Network: Outbound internet access (for Docker image downloads)

Prometheus/Grafana server:

  • CPU: 4 vCPU

  • RAM: 16GB

  • Disk: 100GB+ (see sizing.md for capacity planning)

Documentation:

  • Architecture Overview — diagrams, server types, and port reference

  • Sizing Guide — capacity planning, with series counts measured on both deployments

  • Metrics Mapping — which exporter emits which name, and the canonical ft: names that let one dashboard read all three producers

  • SNMP Notifier Guide — NMS integration, OID tables, SNMPv3

  • Alertmanager API — querying alerts and managing silences

  • Docker + UFW — why Docker bypasses UFW and what to do about it. A Russian translation lives in the repository at docs/ufw-docker-firewall-guide-ru.md; the portal carries the English one.

  • Release Workflow — the agreed trunk-based model. The tooling still does version branches; see Development & Releases below for what actually happens today


Test Environment (Local Stack)

To reproduce a complete environment for testing — the FTACS platform and the monitoring on one machine, from a host with nothing installed — follow the Local Stack guide. It covers the whole path end to end: fetching the code, starting the platform, deploying the exporters against it, the monitoring stack, what a healthy result looks like, and how to read the failures that actually occur.

Note that local-stack deploys the platform itself, so it does not belong on a server that already runs FTACS — there, deploy only the exporters as described below.

Quick Start

Setting up a test environment rather than monitoring real servers? Use the Local Stack — Test Environment Guide: it brings up the FTACS platform and the monitoring together on one machine, from scratch.

ONE-COMMAND INSTALL (Recommended):

This page documents 1.6.9, so every command here installs 1.6.9. The installer defaults to 1.6.9 as well, so the flag is belt and braces — it used to default to main, the last released state, and a host that cached the older wrapper still does. Passing --branch says out loud which version you are installing, which is worth the few characters.

For monitored servers (exporters):

# Auto-detect services and deploy appropriate exporters:
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --branch 1.6.9 PROMETHEUS_IP

# Example:
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --branch 1.6.9 65.108.67.167

# With custom instance prefix (default: DevOps):
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --branch 1.6.9 65.108.67.167 --prefix Prod

For Prometheus/Grafana server (monitoring stack):

One-time on each stack host: two of the images are ours and live in a private project on the hub, so log in before installing. Everything else is public.

docker login hub.friendly-tech.com -u ast   # read-only account, ask for the password

Skip it and the install stops before starting anything, telling you to run exactly this. An air-gapped install needs no login at all — the images come in the bundle.

# Deploy Prometheus + Grafana + Nginx proxy with interactive configuration:
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

# Stack with specific version:
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

Bootstrap options:

Option Description Default Example

--prefix NAME

Instance name prefix for servers.env output

DevOps

--prefix ProdProd-acs1

--token TOKEN

GitHub token for private repository access

-

--token ghp_xxxx

--branch VERSION

Version branch or tag to install. Defaults to the version the installer ships with, which is the one this page documents

1.6.9

--branch 1.6.9

--version-list, -l

Show all available versions (tags + branches)

-

--version-list

--rollback [MANIFEST]

Rollback deployment changes

latest

--rollback

--list-manifests

List available rollback manifests

-

--list-manifests

--diagnose

Collect system diagnostics into a report file

-

--diagnose

--reset-password [PW]

Reset Grafana admin password

-

--reset-password "newpass"

--reset-prometheus-password [PW]

Reset Prometheus basic auth password

-

--reset-prometheus-password "newpass"

--version, -v

Show bootstrap version

-

--version

--docs, --readme

Show documentation links

-

--docs

--local-stack

Configure exporters from a running local-stack without asking

-

bootstrap.sh <IP> auto --local-stack -y

--no-local-stack

Ignore a running local-stack entirely

-

--no-local-stack

--update-only

Alias of --update

-

--update-only

--debug

Verbose tracing for troubleshooting the installer itself

-

--debug

Examples:

# List all available versions (releases and dev branches)
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | bash -s -- --version-list

# Show documentation links
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | bash -s -- --docs

# Reset Grafana admin password (if forgotten or DB persisted from previous install)
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --branch 1.6.9 --reset-password "`new_password`"

# Reset Prometheus basic auth password
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --branch 1.6.9 --reset-prometheus-password "`new_password`"

# Install specific version
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

On the interactive path (no -y), bootstrap.sh stack opens a full-screen arrow-key configuration menu — a hub with sections for Server (IP
access domain), Prometheus (port, retention, basic-auth), Grafana (port, admin login, shared SMTP), FTACS Database (MySQL/Oracle for the Business dashboard), SSL / HTTPS (self-signed / Let’s Encrypt / existing), Alerting (drill into Email/Telegram/Slack/Teams/Webhook/SMS/SNMP), and UI Portal integration, ending in Review & apply.

  • ↑↓ + Enter to navigate; Esc goes back; each section/field shows a help panel and passwords are masked. Nothing is written until you choose Apply.

  • Add -y for a non-interactive run (no menu — auto-derives the domain from the server IP and generates random passwords).

What bootstrap does (exporters):

  1. Installs Docker and Docker Compose (if not present)

  2. Clones this repository to /opt/grafana (or updates existing)

  3. Stashes local changes before update (with restore instructions)

  4. Auto-detects running services (Hazelcast, WildFly, MySQL, Oracle, etc.)

  5. Shows deployment summary with 20-second countdown

  6. Prompts for database credentials interactively (MySQL/Oracle)

  7. Deploys appropriate exporters based on detected services

  8. Configures firewall rules for Prometheus access

Auto-detected services:

Service Exporter Directory Ports

WildFly/FTACS HikariCP

jmx-wildfly

5556

WildFly/FTACS JVM (bare-metal only)

jmx-wildfly (profile jvm)

5557

Hazelcast

(native prometheus)

9101

MySQL/MariaDB

mysql

9104

Oracle DB

oracle

9161

ClickHouse

clickhouse

9363

PostgreSQL

postgres

9187

Nginx (Angular UI)

nginx

9113

FT UI Services

(native endpoints)

8383, 8084, 8881

FT APIs

(native endpoints)

8091, 9880, 8085

(always)

base

9100, 9256, 9183

Base exporters include:

  • node-exporter (9100) - system metrics

  • process-exporter (9256) - host process metrics

  • cadvisor (9183) - Docker container metrics

FT UI Services (native Spring Boot Actuator / FastAPI metrics):

  • ft-device-network (8383) - WiFi mesh management service

  • ai-agent (8084) - AI assistant (Python/FastAPI)

  • ui-backend (8881) - Angular UI backend (when actuator enabled)

FT API Services (native Spring Boot Actuator metrics):

  • provision-api (8091) - Provision Portal API

  • northbound-api (9880) - Northbound integration API

  • service-api (8085) - Service management API

Note: Bootstrap does not support Windows. For Windows servers, install Windows Exporter manually (see section 7).

Manual/Offline Installation (air-gapped servers)

For servers without internet access. Requires pre-downloaded files.

No registry login is needed here. Everything, including the two images we build ourselves, comes from the bundle. If Docker asks for authorization during an offline install, the images are not on the host — see Troubleshooting → Docker asks for authorization, which lists the three reasons in order of likelihood.

Prerequisite: Docker and Docker Compose must already be installed on the air-gapped server — /opt/grafana/misc/install-docker.sh needs internet access (OS package repositories). Use your OS vendor’s offline packages if Docker is missing.

  1. On a machine with internet: download the 1.6.9 version branch as a tarball from the hub — https://hub.friendly-tech.com/repo/1.6.9.tar.gz (extracts as grafana-1.6.9). It asks for the ast account, the same one used to pull our images. The hub rebuilds it on every push to the branch, so it is that branch’s head and not a snapshot. ⚠️ Do NOT take the default main branchmain only receives release merges and lacks the current fixes.

    A tarball rather than the ZIP on purpose: extracting it needs only tar, which the target host must already have to load the image bundle. unzip is absent from a minimal Debian or RHEL install, and on an air-gapped host there is no way to add it — an install that gets that far and then cannot unpack itself is a poor way to find out. The ZIP is still served at …/1.6.9.zip if you prefer it and have unzip.

  2. Enter the extracted directory, then download all Docker images into a bundle:

    cd grafana-1.6.9
    bash misc/download-docker-images.sh
    • Both artifacts are written to the directory you run it from, not into the repository: docker-images-bundle.tar.gz and docker-images-bundle-grafana-plugins/. The script finds the repository by its own location, so it runs from anywhere — but running it from inside misc/ leaves the bundle in misc/, and step 3 below then copies nothing. Run it from the extracted directory as shown.

    • ⚠️ Build the bundle on a host whose Docker uses the classic image store. Docker Desktop and Docker Engine ≥ 28 default to the containerd image store, whose docker save writes an OCI-format archive that older Docker on the target rejects with unrecognized image format on docker load. Use an x86_64 Linux host with the classic (overlay2) store, or disable containerd: /etc/docker/daemon.json{"features":{"containerd-snapshotter":false}} then systemctl restart docker and rebuild.

    • Verify the archive is classic before shipping (must contain manifest.json, not oci-layout): tar tf docker-images-bundle.tar* | grep -m1 manifest.json && echo CLASSIC_OK

    • Images are pulled for linux/amd64 by default (override: PLATFORM=linux/arm64 bash misc/download-docker-images.sh)

    • The bytes come from our own registry, not from six third-party ones. Every image the stack runs is mirrored to hub.friendly-tech.com/mirror/… at the exact version this branch ships, and the bundle saves them under those mirror names — which is what the compose files ask the daemon for, because .env.example pins each image there. So docker load produces exactly what a deployment references. If a version is missing from the mirror the script says so, names the workflow to re-run, and falls back to pulling it from upstream — re-tagged to the mirror name, so the bundle still matches compose — rather than failing quietly. --no-mirror builds the list from the upstream names instead (for checking that an upstream still has what we mirrored; a bundle built that way is not what we ship and will not match a deployment’s .env)

    • The bundle also includes the locally-built stack images (snmp-catcher, alertmanager-config-ui)

    • Grafana plugin packages are downloaded into docker-images-bundle-grafana-plugins/ (Grafana installs plugins from grafana.com at container start, which fails air-gapped)

    • Preview without downloading: bash misc/download-docker-images.sh --list-only

  3. Transfer files to the server. Three things go across, and after step 2 you are inside grafana-1.6.9 while the source tarball is beside it:

    scp ../grafana-1.6.9.tar.gz docker-images-bundle.tar.gz user@server:/tmp/
    scp -r docker-images-bundle-grafana-plugins user@server:/tmp/
  4. On the target server:

    # Load Docker images
    sudo docker load -i /tmp/docker-images-bundle.tar.gz
    
    # Extract repository and deploy exporters.
    # Name the directory explicitly rather than `mv grafana-* grafana`: our own
    # tooling leaves backups like /opt/grafana-backup-20260428-230021, the glob
    # then matches two directories and mv refuses with "target is not a
    # directory" — on exactly the hosts that have been installed before.
    cd /opt && sudo tar xzf /tmp/grafana-1.6.9.tar.gz && sudo mv grafana-1.6.9 grafana
    cd grafana && sudo bash /opt/grafana/misc/quick-deploy.sh PROMETHEUS_IP  # Auto-detects services

What this does:

  1. Loads pre-downloaded Docker images into the local Docker storage

  2. Deploys exporters from local images (no internet required)

  3. Configures firewall rules for Prometheus access

Monitoring stack node (Prometheus/Grafana) offline: once the image bundle is loaded (docker load), install the stack — no manual section-8 steps needed. Pick the mode:

# Interactive (recommended for a manual install) — runs the configurator and
# PROMPTS for GRAFANA_DOMAIN, admin/prometheus passwords, SMTP, etc.:
sudo bash /opt/grafana/misc/bootstrap.sh stack

# Non-interactive (for curl|bash / headless) — asks NOTHING. It auto-derives
# GRAFANA_DOMAIN from the server IP (self-signed cert) and generates random
# admin/prometheus passwords, printed in the final summary:
sudo bash /opt/grafana/misc/bootstrap.sh stack -y

Both use localhost for Prometheus. -y never ships the .env CHANGE_ME placeholders — the must-have values (IP/domain, passwords, service token) are filled automatically; only optional bits (SMTP, Business-dashboard DB) stay unset.

With Docker already present and the repo in place, bootstrap skips the internet-only steps (Docker install, git clone, Let’s Encrypt) and does the rest offline: creates .env from .env.example, generates a self-signed cert + htpasswd + the alertmanager placeholder, starts all containers from the pre-loaded images, deploys the base exporters, and generates targets. It also auto-stages the bundled Grafana plugins: it looks for a docker-images-bundle-grafana-plugins/ folder — with the plugin .zip files directly inside it, no subfolder — in /opt, /tmp and /opt/grafana, checked in that order, and copies its zips into grafana/data/offline-plugins/; the Grafana entrypoint then installs them from disk and skips grafana.com. Auto-staging only runs when offline-plugins/ is empty — if it already holds any .zip, bootstrap leaves it alone.

offline-plugins/ vs plugins/: offline-plugins/ is the input — the zips bootstrap (or you) stage — while plugins/ is the output the entrypoint unzips them into; copying zips into plugins/ does nothing. If you keep the plugin zips outside the three auto-staged locations, stage them manually first:

sudo mkdir -p /opt/grafana/prometheus-grafana-stack/grafana/data/offline-plugins
sudo cp /tmp/docker-images-bundle-grafana-plugins/*.zip \
     /opt/grafana/prometheus-grafana-stack/grafana/data/offline-plugins/
sudo chown -R 472:472 /opt/grafana/prometheus-grafana-stack/grafana/data

The chown matters — Grafana runs as uid 472 and must be able to write under grafana/data.

For Oracle deployments, also extract the Oracle datasource plugin (normally downloaded by bootstrap from GitHub):

sudo mkdir -p /opt/grafana/prometheus-grafana-stack/grafana/data/plugins/albertowd-oraclegrafana-datasource
sudo tar -xzf /tmp/docker-images-bundle-grafana-plugins/albertowd-oraclegrafana-datasource.tar.gz \
     -C /opt/grafana/prometheus-grafana-stack/grafana/data/plugins/albertowd-oraclegrafana-datasource

Tips:

  • TROUBLESHOOTING: Run sudo bash /opt/grafana/misc/diagnose.sh to automatically check Docker, containers, ports, firewall, and Prometheus targets

  • Environment Configuration: All deployments use environment variables (.env files) for secure and flexible configuration

Pre-Deployment Checklist

Before starting, gather the following information:

  • Prometheus Server IP - Will be used in firewall rules on all nodes

  • SMTP Credentials (optional) - For Grafana alerting (host, port, user, password)

  • MySQL Credentials - If deploying MySQL exporter (user, password)

  • Oracle Credentials - If deploying Oracle exporter (user, password)

  • Server IPs - All servers that will be monitored

For clarity in these instructions, we use the following servers:

  • acs1, acs2, hc1, hc2, db, iis

In this example, Prometheus is installed on the same server as the DB. You need to know its IP address during setup to whitelist it on the servers where the exporters are installed:

ACS1 - 65.109.58.164
ACS2 - 65.109.58.100
HC1  - 65.109.24.154
HC2  - 65.108.67.167
DB   - 65.109.58.165 <- PROMETHEUS_IP
IIS  - 65.109.49.150

1. Install Docker and Docker Compose on All Linux Servers

Target servers: acs1, acs2, hc1, hc2, db, *-api servers

Before installing exporters, ensure that Docker and Docker Compose are installed on all target servers (except Windows-based servers).

Use the automated installation script:

# If repository is already cloned:
cd /opt/grafana
sudo bash /opt/grafana/misc/install-docker.sh

# Or via one-command bootstrap (installs Docker automatically):
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- PROMETHEUS_IP --branch 1.6.9

The script automatically:

  • ✅ Detects your OS and package manager

  • ✅ Fixes EOL repos (CentOS 7/8)

  • ✅ Installs Docker CE from official repository

  • ✅ Installs Docker Compose plugin (falls back to static binary if needed)

  • ✅ Enables and starts Docker service

  • ✅ Verifies installation

Verify Installation

docker -v
docker compose version

Expected output (versions may vary):

Docker version 28.0.1, build 068a01e
Docker Compose version v2.33.1

Manual Installation (if script fails)

Ubuntu/Debian:
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update && sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker
CentOS/Rocky/AlmaLinux:
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker

2. Install Node Exporter and Process Exporter

Target servers: acs1, acs2, hc1, hc2, db, *-api servers

Use the automated deployment script with service auto-detection:

# Auto-detect services and deploy (recommended):
cd /opt/grafana
sudo bash /opt/grafana/misc/quick-deploy.sh PROMETHEUS_IP

# Examples:
sudo bash /opt/grafana/misc/quick-deploy.sh 65.108.67.167

# Or deploy specific exporters manually (--exporter flag):
sudo bash /opt/grafana/misc/quick-deploy.sh 65.108.67.167 --exporter base --exporter jmx-wildfly
sudo bash /opt/grafana/misc/quick-deploy.sh 65.108.67.167 --exporter base --exporter mysql

# Or use one-command bootstrap (installs Docker, clones repo, deploys):
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- 65.108.67.167 --branch 1.6.9

--exporter flag: Specifies which exporters to deploy from exporters/ folder. Use base (always required) plus service-specific: jmx-wildfly, mysql, oracle, jmx-hazelcast, clickhouse, postgres, nginx. Hazelcast: embedded-in-ACS needs no exporter (native :9101), standalone/docker needs jmx-hazelcast (JMX :9110 → :9101). The jmx-wildfly directory holds two services: the HikariCP exporter (5556) starts always, while the JVM exporter (5557) sits behind the jvm compose profile. Docker FTACS has a built-in javaagent on 5557, so quick-deploy enables that profile only on bare-metal installations.

Service Auto-Detection: The script automatically detects running services and deploys appropriate exporters:

Detected Service Exporter Directory Detection Method

WildFly/FTACS

jmx-wildfly (JVM exporter via profile jvm)

jboss-modules.jar, -Djboss.home.dir

Hazelcast

(native prometheus)

HazelcastMemberStarter, hazelcast-*.jar

MySQL/MariaDB

mysql

mysqld process, systemd service

Oracle DB

oracle

ora_pmon_* process

ClickHouse

clickhouse

clickhouse-server process, systemd service

PostgreSQL

postgres

postgres process, systemd service

Nginx (Angular UI)

nginx

Docker container portals, nginx process on port 8880

FT Device Network

(native endpoint)

Docker container ft-device-network

AI Agent

(native endpoint)

Docker container ui-ai-agent

UI Backend

(native endpoint)

Docker container ui-backend

Provision API

(native endpoint)

Docker container provision-api

Northbound API

(native endpoint)

Docker container northbound-api

Service API

(native endpoint)

Docker container service-api

(always)

base

Always deployed (node + process + cadvisor)

The script automatically:

  • ✅ Detects running services on the server

  • ✅ Shows deployment summary with confirmation prompt

  • ✅ Prompts for database credentials if needed (MySQL/Oracle)

  • ✅ Deploys Node Exporter (port 9100) - system metrics

  • ✅ Deploys Process Exporter (port 9256) - per-process metrics

  • ✅ Deploys service-specific exporters based on detection

  • ✅ Configures firewall rules with persistence

  • ✅ Verifies deployment

Manual Installation (click to expand)

If you prefer manual installation or need to customize the setup:

2.0 Optional: Change Exporter Ports

All exporters are configured to run in network_mode: host (they bind directly to host network). By default, they use these ports:

Exporter Default Port Purpose

Node Exporter

9100

System metrics

Process Exporter

9256

Host process metrics

cAdvisor

9183

Docker container metrics

MySQL Exporter

9104

MySQL database metrics

Oracle DB Exporter

9161

Oracle database metrics

ClickHouse (native)

9363

ClickHouse database metrics

PostgreSQL Exporter

9187

PostgreSQL database metrics

Nginx Exporter

9113

Nginx web server metrics

JMX Exporter

5556

JMX metrics (Hikari)

JMX Exporter

5557

JVM metrics

If you need to override these ports (e.g., due to conflicts or multi-instance deployment):

  • Uncomment the relevant --web.listen-address=…​ or command: […​] line in docker-compose.yml.

  • Update firewall and Prometheus scrape configs accordingly.

2.1 Get the Repository

Three ways, in the order you should prefer them. All of them must give you the 1.6.9 branch — that is the version this page describes.

Fetches the branch into /opt/grafana and needs no credentials on the server:

curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --branch 1.6.9 --update

--update fetches the code and stops, leaving the deployment steps below to you. Drop it to have the installer continue into the deployment itself.

Option B: Download the branch by hand

For a server that cannot run the installer — no GitHub account is needed, only the hub:

cd /opt
curl -u ast -fsSLo grafana-1.6.9.tar.gz https://hub.friendly-tech.com/repo/1.6.9.tar.gz
tar xzf grafana-1.6.9.tar.gz          # extracts as grafana-1.6.9/
mv grafana-1.6.9 grafana              # the layout below expects /opt/grafana

A tarball rather than the ZIP: unpacking it needs only tar, while unzip is absent from a minimal Debian or RHEL install. The archive holds one top-level directory, so it has to be renamed — unpacking it into /opt/grafana would give you /opt/grafana/grafana-1.6.9/…, and nothing would find its configuration there.

Option C: Clone with Git

The repository is private, so this asks for credentials — which is why it is not the default on a server:

cd /opt
git clone -b 1.6.9 https://github.com/Friendly-Technologies/grafana.git grafana

-b 1.6.9 is not optional: without it git takes the default branch, which is the last released state rather than the version documented here.

Option D: Download an archive from the hub (no git access needed)

For anyone without a GitHub account — support, most often. The hub carries an archive of every branch, rebuilt on each push to it and removed when the branch goes, so what you download is that branch’s head and not a snapshot from whenever someone last remembered to upload one.

# What is available, with the commit each archive was built from
curl -u ast https://hub.friendly-tech.com/repo/INDEX.txt

# This version
curl -u ast -O https://hub.friendly-tech.com/repo/1.6.9.tar.gz
cd /opt && sudo tar xzf ~/1.6.9.tar.gz && sudo mv grafana-1.6.9 grafana

It asks for a password: the archives are the private repository’s source, so the directory is behind HTTP basic auth. The account is ast — the same read-only one used to pull our images — and the password is the one already handed to you for the registry. Downloads are logged with the account name.

The extension chooses what you get, on the same base name:

  • 1.6.9.tar.gz — source, for a server: unpacking needs only tar, and unzip is absent from a minimal Debian or RHEL install

  • 1.6.9.zip — source, for a workstation

  • 1.6.9.pdf — this installation guide, as of that branch

  • 1.6.9-sizing.pdf, 1.6.9-architecture.pdf — the other two documents

The PDFs are built from the branch they are named after, so the guide you read matches the code you downloaded — which is the point of keeping them together.

The file is named after the branch, so the URL reads /repo/1.6.9.tar.gz; a slash in a branch name becomes an underscore, making fix/shell-trap-lint into fix_shell-trap-lint.tar.gz. INDEX.txt spells out which file belongs to which branch. Inside, the archive still unpacks as grafana-<branch>/, which is why the commands above rename it.

2.2 File Structure

After downloading or cloning, you should have the following file structure:

/opt
└── grafana
    ├── exporters/                    # Modular exporter directories
    │   ├── base/                     # Node + Process + cAdvisor (all servers)
    │   │   ├── docker-compose.yml
    │   │   ├── process-exporter.yml
    │   │   └── .env.example
    │   ├── jmx-wildfly/             # HikariCP exporter (5556) + JVM exporter
    │   │                            #   (5557, compose profile 'jvm', bare-metal only)
    │   │   ├── docker-compose.yml
    │   │   ├── config-hikari.yml
    │   │   ├── config-jvm.yml
    │   │   └── .env.example
    │   ├── mysql/                    # MySQL/MariaDB exporter
    │   │   ├── docker-compose.yml
    │   │   └── .env.example
    │   ├── oracle/                   # Oracle DB exporter
    │   │   ├── docker-compose.yml
    │   │   ├── config.yaml.template
    │   │   ├── generate-config.sh
    │   │   └── .env.example
    │   ├── clickhouse/               # ClickHouse native Prometheus endpoint (no exporter container)
    │   │   ├── clickhouse-prometheus.xml   # config.d snippet (port 9363)
    │   │   └── install-native.sh           # installs the config + optional restart
    │   ├── postgres/                 # PostgreSQL exporter
    │   │   ├── docker-compose.yml
    │   │   └── .env.example
    │   └── nginx/                    # Nginx exporter (for Angular UI)
    │       ├── docker-compose.yml
    │       └── .env.example
    ├── prometheus-grafana-stack/     # Monitoring stack
    │   ├── docker-compose.yml
    │   ├── .env.example
    │   └── prometheus/
    │       ├── prometheus.yml
    │       ├── scripts/
    │       │   └── generate-targets.sh
    │       └── targets/
    │           └── servers.env.example
    ├── docs/
    │   └── architecture.md           # Architecture diagrams
    ├── misc/
    │   ├── bootstrap.sh              # One-command installer
    │   ├── quick-deploy.sh           # Auto-detect and deploy exporters
    │   ├── detect-services.sh        # Service auto-detection
    │   ├── diagnose.sh               # Troubleshooting tool
    │   └── install-docker.sh         # Docker installer
    ├── README.md
    └── sizing.md                     # Capacity planning guide

2.3 Deploy Exporters

The deployment automatically detects running services and deploys appropriate exporters:

# Auto-detect services and deploy (recommended)
cd /opt/grafana
sudo bash /opt/grafana/misc/quick-deploy.sh 65.109.58.165

For manual deployment, navigate to the relevant exporter directory:

# Base exporters (Node + Process) - all servers
cd /opt/grafana/exporters/base

# Hazelcast: No exporter needed - uses native Prometheus endpoint (port 9101)
# See section "Prerequisites: Enable Prometheus on Hazelcast Servers"

# JMX exporters for WildFly/FTACS
cd /opt/grafana/exporters/jmx-wildfly
  1. Configure environment variables:

    Copy the example environment file and edit it with your configuration:

    cp .env.example .env
    vi .env

    The .env file contains configuration for exporter versions, ports, and other settings. Most values have sensible defaults and can be left as-is. Key variables:

    • NODE_EXPORTER_PORT - Default: 9100

    • PROCESS_EXPORTER_PORT - Default: 9256

    • JMX_HIKARI_PORT - Default: 5556 (for ACS servers)

    • JMX_JVM_PORT - Default: 5557 (for ACS servers)

      Note: If you don’t create a .env file, the default values will be used automatically.

  2. Start the exporters using Docker Compose:

    docker compose up -d

    Hint: You can check the logs of the started containers:

    docker compose logs -f
  3. Enable Prometheus to access exporter ports:

    iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9100 -j ACCEPT   # node-exporter
    iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9256 -j ACCEPT   # process-exporter
    iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9183 -j ACCEPT   # cadvisor
    # JMX ports (if WildFly detected):
    iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 5556 -j ACCEPT   # jmx-hikari
    iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 5557 -j ACCEPT   # jmx-jvm
    # Hazelcast native Prometheus (if Hazelcast detected):
    iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9101 -j ACCEPT   # hazelcast native

    In our example we took ip address of prometheus and commands should be looking like that:

    iptables -A INPUT -p tcp -s 65.109.58.165 --dport 9100 -j ACCEPT   # node-exporter
    iptables -A INPUT -p tcp -s 65.109.58.165 --dport 9256 -j ACCEPT   # process-exporter
    iptables -A INPUT -p tcp -s 65.109.58.165 --dport 9183 -j ACCEPT   # cadvisor
    # JMX ports (if WildFly detected):
    iptables -A INPUT -p tcp -s 65.109.58.165 --dport 5556 -j ACCEPT   # jmx-hikari
    iptables -A INPUT -p tcp -s 65.109.58.165 --dport 5557 -j ACCEPT   # jmx-jvm
    # Hazelcast native Prometheus (if Hazelcast detected):
    iptables -A INPUT -p tcp -s 65.109.58.165 --dport 9101 -j ACCEPT   # hazelcast native

3. Install JMX Exporter (WildFly/ACS Servers)

Target servers:

  • acs1, acs2 - WildFly/FTACS application servers

Note: Hazelcast monitoring depends on how Hazelcast runs:

  • Embedded in ACS/WildFly — the native Prometheus endpoint on :9101 already emits the full com_hazelcast_* MBean set (incl. isClusterSafe); no exporter needed.

  • Standalone / docker Hazelcast — the native endpoint (PROMETHEUS_PORT=9101) emits only com_hazelcast_Metrics_* (no isClusterSafe), so the dashboard shows “No data”. Deploy the jmx-hazelcast exporter instead — it scrapes Hazelcast JMX (:9110) and serves :9101 with the full set. Auto-detection deploys it automatically when it finds a standalone Hazelcast with JMX enabled.

Skip this section entirely on ACS 6.5 and newer. From 6.5 the platform serves JVM and HikariCP metrics through its own Actuator endpoint, which the ACS_METRICS target already scrapes on :8080 — measured on 6.5.2 and 7.1.1, 98 metric families including jvm_memory_used_bytes, jvm_gc_* and hikaricp_connections_*. Deploying jmx-wildfly there adds nothing: the exporter connects to a JMX port the container does not open and serves six metrics about itself. ACS 6.4 and earlier serve a legacy endpoint with 14 business metrics and no JVM data at all, so those hosts do need the JMX exporters described below.

Check which one you have: curl -s http://<acs>:8080/actuator/prometheus | head -1 answers with # HELP on 6.5+, and 404s on 6.4.

Skip this section if: Auto-detection already deployed JMX exporters.

JMX Exporter Types

Server Type Exporter Directory Ports Metrics

WildFly/FTACS (6.4 and earlier)

jmx-wildfly (JVM part: profile jvm)

5556 / 5557

HikariCP pool / JVM heap/GC/threads

FTACS 6.5+

(none — Actuator on :8080)

8080

Same data, via ACS_METRICS

Hazelcast

(native prometheus)

9101

Cluster members, partitions, maps, JVM

Quick Installation

cd /opt/grafana

# Auto-detect (recommended) - deploys appropriate exporters based on detected services
sudo bash /opt/grafana/misc/quick-deploy.sh 65.108.67.167

# Or deploy specific exporters manually:
# For WildFly/FTACS servers:
sudo bash /opt/grafana/misc/quick-deploy.sh 65.108.67.167 --exporter base --exporter jmx-wildfly

Note: The --exporter flag specifies which exporters to deploy from exporters/ folder.

  • base - always required (node-exporter + process-exporter + cAdvisor)

  • jmx-wildfly, mysql, oracle - service-specific exporters

  • Hazelcast: embedded-in-ACS uses the native Prometheus endpoint (:9101); standalone/docker needs the jmx-hazelcast exporter (JMX :9110 → :9101)

Verify JMX Exporters

# Check containers are running
docker ps | grep jmx

# Test metrics endpoints
# WildFly:
curl -s http://localhost:5556/metrics | head  # HikariCP
curl -s http://localhost:5557/metrics | head  # JVM

# Hazelcast:
curl -s http://localhost:9101/metrics | head  # Hazelcast

Important: JMX ports are service-specific — do NOT mix them:

  • FTACS/WildFly JMX: port 9999 (configured in standalone.conf)

  • Hazelcast JMX: port 9110 (configured via HZ_JMX_INTERNAL_PORT env var)

  • Hazelcast Prometheus: port 9101 — embedded-in-ACS: native endpoint (full MBean set). Standalone/docker: served by the jmx-hazelcast exporter scraping JMX :9110 (native alone omits isClusterSafe).

JMX Exporters (ports 5556/5557) connect to FTACS JMX on port 9999. For standalone Hazelcast, the jmx-hazelcast exporter connects to Hazelcast JMX on :9110 and serves Prometheus on :9101. Embedded-in-ACS Hazelcast uses its native :9101 endpoint (no JMX connection needed).

Manual: Enable JMX on WildFly/ACS (port 9999)

Before JMX exporters can collect metrics, WildFly/ACS (NOT Hazelcast) must be configured to expose JMX on port 9999. One-time configuration on each ACS server.

Step 1: Add to /usr/local/FTACS/bin/standalone.conf:

JAVA_OPTS="$JAVA_OPTS \
    -Dcom.sun.management.jmxremote \
    -Dcom.sun.management.jmxremote.port=9999 \
    -Dcom.sun.management.jmxremote.rmi.port=9999 \
    -Dcom.sun.management.jmxremote.ssl=false \
    -Dcom.sun.management.jmxremote.authenticate=false \
    -Djava.rmi.server.hostname=127.0.0.1"

Step 2: Enable HikariCP MBeans in /usr/local/FTACS/standalone/configuration/ftacs_hikari.properties:

registerMbeans=true

Step 3: Restart ACS:

# For MySQL:
service jboss restart

# For Oracle:
service jboss restartoracle

Tip: Check current DB type: ps aux | grep -o '\-Dftacs.usedDBMS=[^ ]*'

Verify: ss -tlnp | grep 9999

Manual: Enable Prometheus on Hazelcast

Hazelcast uses native Prometheus endpoint on port 9101. The quick-deploy.sh script can configure this automatically for native installations.

Native installation (systemd/init.d):

Step 1: Add to /usr/local/hazelcast-5.0/bin/hz-start:

export PROMETHEUS_PORT=9101

Step 2: Restart Hazelcast: systemctl restart hazelcast5

Hazelcast in Docker:

If Hazelcast runs in a Docker container, add the following to your docker run script:

  -e PROMETHEUS_PORT=9101 \   # Enable native Prometheus metrics
  -p 9101:9101 \              # Expose metrics port to host

Then restart the container.

Note: No separate JMX exporter is needed for Hazelcast — it exposes metrics natively on port 9101. JMX exporters (ports 5556/5557) are only for WildFly/FTACS.

Verify: curl -s http://localhost:9101/metrics | head


4. Install MySQL Exporter

Target server: db

Use the automated deployment script:

# If you already have Docker installed and repository cloned:
cd /opt/grafana
sudo bash /opt/grafana/misc/quick-deploy.sh 65.109.58.165 --exporter base --exporter mysql

# Or use one-command bootstrap (auto-detects MySQL):
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- 65.109.58.165 --branch 1.6.9

Important: Before running the script, create the MySQL exporter user (see step 1 below).

Manual Installation (click to expand)

Run the following commands in the shell console on the target server:

4.0 Optional: Change Exporter Port

By default, MySQL Exporter listens on port 9104. To override it (e.g., due to a conflict or multiple exporters):

  1. Open /opt/grafana/exporters/mysql/docker-compose.yml

  2. Uncomment and modify the --web.listen-address=:PORT parameter in the command: block:

# Uncomment to override default port (9104)
# - '--web.listen-address=:19104'

4.1 Setup Steps

  1. Create an exporter user in MySQL:

    Log in as root or admin and run the following commands (generate a secure password and replace StR0nG_PassW0rD_hErE accordingly):

    CREATE USER IF NOT EXISTS 'exporter'@'%' IDENTIFIED BY 'StR0nG_PassW0rD_hErE' WITH MAX_USER_CONNECTIONS 3;
    GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'%';
  2. Clone or download this repo into /opt/grafana, in the same way as described in step 2.

  3. Configure environment variables:

    Navigate to the MySQL exporter directory and create the .env file:

    cd /opt/grafana/exporters/mysql/
    cp .env.example .env
    vi .env

    Update the following variables with your MySQL credentials:

    # MySQL Connection Details (REQUIRED)
    MYSQL_HOST=localhost
    MYSQL_PORT=3306
    MYSQL_USER=exporter
    MYSQL_PASSWORD=StR0nG_PassW0rD_hErE  # Replace with your password

    Optional variables (can be left as default):

    • MYSQLD_EXPORTER_VERSION - Default: latest

    • MYSQLD_EXPORTER_PORT - Default: 9104

  4. Start the exporter using Docker Compose:

    docker compose up -d
  5. Enable Prometheus to access exporter ports:

    iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9104 -j ACCEPT

5. Install Oracle DB Exporter

Target server: oracle-db or any server with Oracle Database access

Use the automated deployment script:

# If you already have Docker installed and repository cloned:
cd /opt/grafana
sudo bash /opt/grafana/misc/quick-deploy.sh 65.109.58.165 --exporter base --exporter oracle

# Or use one-command bootstrap (auto-detects Oracle):
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- 65.109.58.165 --branch 1.6.9

Note: Bootstrap and quick-deploy will prompt for Oracle credentials interactively.

Manual Installation (click to expand)

Run the following commands in the shell console on the target server:

5.1 Setup Oracle DB Exporter

  1. Clone or download this repo into /opt/grafana, in the same way as described in step 2.

  2. Navigate to the Oracle exporter directory:

    cd /opt/grafana/exporters/oracle/
  3. Configure environment variables:

    Create the .env file with your Oracle Database credentials:

    cp .env.example .env
    vi .env

    Update the following variables:

    # Oracle Database Connection (REQUIRED)
    ORACLE_HOST=localhost
    ORACLE_PORT=1521
    ORACLE_SERVICE_NAME=ORCL
    ORACLE_USER=system
    ORACLE_PASSWORD=your_oracle_password

    Optional variables (can be left as default):

    • ORACLE_EXPORTER_VERSION - Default: 2.2.0

    • ORACLE_EXPORTER_PORT - Default: 9161

  4. Generate config.yaml from template:

    ./generate-config.sh

    This script reads .env and generates config.yaml with substituted values.

  5. Start the exporter using Docker Compose:

    docker compose up -d

    Hint: You can check the logs of the started container:

    docker compose logs -f
  6. Verify the exporter is working:

    curl http://localhost:9161/metrics

    You should see Oracle Database metrics including:

    • oracledb_sessions_value - Database sessions

    • oracledb_tablespace_bytes - Tablespace usage

    • oracledb_activity_* - Database activity metrics

    • oracledb_resource_* - Resource utilization

  7. Enable Prometheus to access exporter port:

    iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9161 -j ACCEPT

    In our example:

    iptables -A INPUT -p tcp -s 65.109.58.165 --dport 9161 -j ACCEPT

Remote Database Monitoring

If your database runs on a separate server (e.g., Windows server, cloud RDS, or a different network segment) where you cannot install Docker exporters, you can run the exporter on any Linux server that has network access to the database.

Step 1: On the Linux server (where exporter will run), set the host to the remote database IP:

cd /opt/grafana/exporters/oracle
cp .env.example .env
vi .env
ORACLE_HOST=<database-server-ip>    # Remote database server IP (not localhost)
ORACLE_PORT=1521
ORACLE_SERVICE_NAME=<service-name>
ORACLE_USER=<username>
ORACLE_PASSWORD=<password>

Step 2: Generate config and start:

cd /opt/grafana/exporters/oracle
bash /opt/grafana/exporters/oracle/generate-config.sh
docker compose up -d

Step 3: Verify exporter connects to remote database:

curl http://localhost:9161/metrics | grep oracledb_up
# Expected: oracledb_up 1

Step 4: Add to Prometheus targets. Since the exporter runs locally, use 127.0.0.1:

cd /opt/grafana/prometheus-grafana-stack/prometheus/targets
vi servers.env
# Add line:
ORACLE 127.0.0.1:9161 <instance-name>

# Regenerate targets:
bash /opt/grafana/prometheus-grafana-stack/prometheus/scripts/generate-targets.sh

Note: This approach works for any database exporter (MySQL, PostgreSQL, ClickHouse, Oracle). The exporter connects to the database over the network — only the database port (e.g., 1521, 3306) needs to be open between the exporter and the database server. No Docker or exporter installation is needed on the database server itself.

Firewall: Ensure the database port is open from the exporter server to the database server. No need to open exporter port (9161) externally — Prometheus scrapes it locally.

Mixed Deployment (Some Services on Same Server as Grafana)

When some monitored services run on the same server as Grafana/Prometheus but in a separate Docker Compose stack, those containers may be in a different Docker network. Prometheus runs in network_mode: host and cannot always reach containers in bridge networks through docker-proxy (connections hang/timeout).

Services on other servers are not affected — standard port mapping works fine over the network.

How to identify the problem:

Solution: Switch only the affected service to network_mode: host. Services on other servers keep their normal networking.

Example: Hazelcast on same server as Grafana

Before (bridge network — metrics not accessible from Prometheus):

hazelcast:
    image: hub.friendly-tech.com/components/hazelcast:ft.5.5.0
    ports:
      - "5701:5701"
      - "5711:5711"
      - "9101:9101"
    environment:
      PROMETHEUS_PORT: 9101

After (host network — metrics accessible on localhost):

hazelcast:
    image: hub.friendly-tech.com/components/hazelcast:ft.5.5.0
    network_mode: host
    environment:
      PROMETHEUS_PORT: 9101

network_mode: host puts every one of the service’s ports on all interfaces, including the public one — there is no ports: list left to restrict. For Hazelcast that means 5701 and the JMX port face the internet, and an open Hazelcast member port is a known remote-code-execution vector. Firewall them in the DOCKER-USER chain (UFW alone does not apply to Docker traffic — see the Docker + UFW guide):

iptables -I DOCKER-USER -p tcp -m multiport --dports 5701,5711,9101,9110 \
         ! -s <monitoring-server-ip>/32 -j DROP

Important changes when switching a service to network_mode: host:

  • Remove ports: section for that service (not needed — all ports are on host directly)

  • Replace container hostname references with 127.0.0.1 in environment variables:

  • HZ_MEMBERS=hazelcast:5701HZ_MEMBERS=127.0.0.1:5701

  • HZ_PUBLIC_ADDRESS=hazelcast:5711HZ_PUBLIC_ADDRESS=127.0.0.1:5711

  • Other containers that connect to this service by Docker DNS name must also be updated to use 127.0.0.1 or the host IP

Prometheus target configuration:

For services on the same server — use 127.0.0.1:

JMX_HC 127.0.0.1:9101 <instance-name>

For services on other servers — use their IP as usual:

JMX_HC <server-ip>:9101 <instance-name>

Verify:

# Should return metrics immediately (no hang)
curl -s http://localhost:9101/metrics | head

Note: Only services co-located with Grafana/Prometheus need this change. Services on separate servers work with standard bridge networking and port mapping.

Note: This issue occurs because Prometheus uses network_mode: host and docker-proxy between host and bridge networks can be unreliable on some kernel/Docker versions.


6. Install FT UI Services Monitoring (DEV-524)

Target server: hc1 (65.109.24.154) - Angular UI services server

This section covers monitoring for the Angular UI services stack deployed via Docker Compose.

Components Monitored

Service Type Port Metrics Path Description

portals (nginx)

Web server

8880/8843

via exporter

Angular UI frontend

ui-backend

Spring Boot

8881

/actuator/prometheus

Angular backend API

ft-device-network

Spring Boot

8383

/api/actuator/prometheus

WiFi mesh service

ui-ai-agent

Python/FastAPI

8084

/metrics

AI assistant

clickhouse

Database

9363

/metrics (native endpoint)

Analytics database

postgres

Database

5432

via exporter

AI agent database

The bootstrap script automatically detects FT UI services when run on HC1:

# Auto-detect and deploy all exporters (recommended):
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- PROMETHEUS_IP --branch 1.6.9

# Or if repository already cloned:
cd /opt/grafana
sudo bash /opt/grafana/misc/quick-deploy.sh PROMETHEUS_IP

The script will automatically deploy:

  • nginx-exporter (port 9113) - for Angular UI (portals container)

  • ClickHouse native Prometheus endpoint (port 9363) - for ClickHouse database

  • postgres-exporter (port 9187) - for PostgreSQL database

Native endpoints (no exporter needed):

  • ft-device-network (port 8383) - Spring Boot Actuator

  • ai-agent (port 8084) - FastAPI with prometheus_client

  • ⚠️ ui-backend (port 8881) - Requires Spring Security configuration (see below)

Manual Installation

Nginx Exporter (for Angular UI portals)

Step 1: Enable nginx stub_status in portals container

The portals nginx must expose /nginx_status endpoint. Add the following block to the nginx config inside the portals container:

location /nginx_status {
    stub_status on;
    access_log off;
    allow 127.0.0.1;
    deny all;
}

How to find and edit the config:

# Check where nginx config is located inside the container
docker exec portals nginx -T 2>/dev/null | head -5

# Common locations:
#   /etc/nginx/conf.d/default.conf
#   /etc/nginx/nginx.conf

# View the current config
docker exec portals cat /etc/nginx/conf.d/default.conf

# Edit: copy config out, add the location block, copy back
docker cp portals:/etc/nginx/conf.d/default.conf ./default.conf
# ... add the location /nginx_status block inside the server { } section ...
docker cp ./default.conf portals:/etc/nginx/conf.d/default.conf
docker exec portals nginx -s reload

Verify stub_status is working:

docker exec portals curl -s http://127.0.0.1/nginx_status
# Expected output:
#   Active connections: 5
#   server accepts handled requests
#
1234 1234 5678
#   Reading: 0 Writing: 1 Waiting: 4

Note: This change needs to be persisted in the portals Docker image or compose volume mount, otherwise it will be lost on container restart.

Step 2: Deploy nginx-exporter

cd /opt/grafana/exporters/nginx
cp .env.example .env
vi .env

Update:

NGINX_HOST=localhost  # or container name if using Docker network
NGINX_PORT=8880
NGINX_EXPORTER_PORT=9113

Step 3: Start exporter

docker compose up -d

Step 4: Configure firewall

iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9113 -j ACCEPT

Verify:

curl http://localhost:9113/metrics | grep nginx_

ClickHouse metrics (native Prometheus endpoint)

ClickHouse exposes Prometheus metrics natively — no exporter container. The old f1yegor/clickhouse-exporter is not used: it crash-loops on modern ClickHouse (21.8+) because it does not sanitise block-device async metric names (clickhouse_block_discard_ops_dm-4 is not a valid Prometheus metric name).

Step 1: Enable the native endpoint on the ClickHouse host (port 9363)

sudo bash /opt/grafana/exporters/clickhouse/install-native.sh --restart

This drops clickhouse-prometheus.xml into /etc/clickhouse-server/config.d/ and restarts clickhouse-server. Omit --restart to install the config and restart the DB yourself later. The endpoint binds to ClickHouse’s configured <listen_host> — if ClickHouse already answers on the server IP (HTTP 8123), 9363 binds there too.

Step 2: Configure firewall

iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9363 -j ACCEPT

Step 3: Add to the Prometheus server’s servers.env, then run generate-targets.sh

CLICKHOUSE  <server-ip>:9363  <instance>  role=database

Verify (native metric names, not clickhouse_*):

curl -s http://localhost:9363/metrics | grep -E '^ClickHouse(Metrics|ProfileEvents|AsyncMetrics)_'

PostgreSQL Exporter

Step 1: Create exporter user in PostgreSQL

-- Use a generated password, not a memorable one; this account is reachable
-- from wherever the database is. e.g.  openssl rand -base64 24
CREATE USER exporter WITH PASSWORD 'REPLACE_WITH_GENERATED_PASSWORD';
GRANT pg_monitor TO exporter;
GRANT CONNECT ON DATABASE ftl_ai TO exporter;

Step 2: Let the exporter reach PostgreSQL — bind to localhost, never to all interfaces

Publishing a database port as "5432:5432" exposes it to the internet. Docker writes its own iptables rules and bypasses UFW, so a firewall that looks correct does not protect a published port. This is not theoretical: this exact line was followed on a customer host and cryptominers appeared in both containers the next day. Full explanation in the Docker + UFW guide (Russian version in the repository: docs/ufw-docker-firewall-guide-ru.md).

The exporter runs with host networking, so binding to loopback is enough:

postgres:
  ports:
    - "127.0.0.1:5432:5432"   # loopback ONLY — the exporter reaches it, the internet does not

If the database must stay reachable from other hosts (published as 5432:5432 for other services), do not rely on UFW. Restrict it in the DOCKER-USER chain, which Docker does not overwrite:

iptables -I DOCKER-USER -p tcp --dport 5432 ! -s <allowed-ip>/32 -j DROP

Preferred alternative — publish nothing at all. Attach the exporter to the database’s own Docker network; it then talks to PostgreSQL container-to-container and no port is exposed anywhere:

cd /opt/grafana/exporters/postgres
# find the database's network
docker inspect <postgres-container> --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}'
# in .env:
#   POSTGRES_DOCKER_NETWORK=ft-ui-services_default
#   POSTGRES_HOST=postgres
# service name, not 127.0.0.1
#   POSTGRES_PORT=5432
docker compose -f docker-compose.yml -f docker-compose.network.yml up -d

This is also the only variant that works on a hardened host that drops outbound traffic to private ranges: a rule such as OUTPUT -d 172.16.0.0/12 -j DROP covers every default Docker subnet, so a host-networked exporter cannot reach the container even by IP, while a container on the same bridge is unaffected.

Step 3: Deploy postgres-exporter

cd /opt/grafana/exporters/postgres
cp .env.example .env
vi .env

Update:

POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=exporter
POSTGRES_PASSWORD=<the generated password from Step 1>
POSTGRES_DB=ftl_ai
POSTGRES_EXPORTER_PORT=9187

Step 4: Start exporter

docker compose up -d

Step 5: Configure firewall

iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 9187 -j ACCEPT

Verify:

curl http://localhost:9187/metrics | grep pg_

Native Metrics Endpoints

These services expose Prometheus metrics natively - no separate exporter needed:

FT Device Network Service (Spring Boot Actuator)

Port: 8383 Path: /api/actuator/prometheus Type: Spring Boot Actuator

Metrics are enabled by default with micrometer-prometheus dependency.

Verify:

curl http://localhost:8383/api/actuator/prometheus | grep jvm_

Add to Prometheus (servers.env):

FT_DEVICE_NETWORK 65.109.24.154:8383 DevOps-HC1

Configure firewall:

iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 8383 -j ACCEPT

AI Agent (Python/FastAPI)

Port: 8084 Path: /metrics Type: FastAPI with prometheus_client middleware

Metrics are exposed using prometheus_client library.

Expose the metrics port — the default ft-ui-services compose does not publish the AI Agent port to the host. Add a port mapping to the ui-ai-agent service in the ft-ui-services compose file and recreate the container:

  ui-ai-agent:
    ports:
      - "8084:8080"
docker compose up -d ui-ai-agent

Verify:

curl http://localhost:8084/metrics | grep python_

Add to Prometheus (servers.env):

AI_AGENT 65.109.24.154:8084 DevOps-HC1

Configure firewall:

iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 8084 -j ACCEPT

UI Backend (Spring Boot Actuator) - Optional

Port: 8881 Path: /actuator/prometheus Type: Spring Boot Actuator

⚠️ Important: UI Backend requires Spring Security configuration to allow /actuator/** endpoints.

Prerequisites:

Add to Spring Security configuration:

.requestMatchers("/actuator/**").permitAll()

Or update application.properties:

management.endpoints.web.exposure.include=prometheus,health,info

Verify:

curl http://localhost:8881/actuator/prometheus | grep jvm_

Add to Prometheus (servers.env):

UI_BACKEND 65.109.24.154:8881 DevOps-HC1

Configure firewall:

iptables -A INPUT -p tcp -s $PROMETHEUS_IP --dport 8881 -j ACCEPT

Grafana Dashboard

The FT UI Services dashboard (ft-ui-services.json) provides monitoring for all components:

  • Nginx (Portals) - Active connections, requests/sec, response times

  • ClickHouse - Queries, connections, disk usage, replication lag

  • PostgreSQL - Connections, transactions, cache hit ratio, replication

  • FT Device Network - JVM heap, threads, HTTP requests, API latency

  • AI Agent - Python memory, request rate, response times

  • UI Backend - JVM metrics, HTTP requests, database connections (when enabled)

Dashboard is auto-provisioned at: Dashboards → Extended → FT UI Services


7. Install Windows Exporter

Target server: iis

Note: Windows Exporter installation is manual only (no automated script available).

Download and Install

  1. Download from Windows Exporter Releases

  2. Run in Command Prompt (as Administrator):

    lodctr.exe /E:Lsa
    lodctr.exe /E:PerfProc
    lodctr.exe /R
    
    msiexec /i "C:\Users\Administrator\windows_exporter-0.31.3-amd64.msi" ENABLED_COLLECTORS=ad,adfs,cache,cpu,cpu_info,container,dfsr,dhcp,dns,fsrmquota,iis,logical_disk,memory,msmq,mssql,net,os,process,remote_fx,service,tcp,time,vmware LISTEN_PORT=9100 EXTRA_FLAGS="--collector.process.include=""(w3wp|java.*)"" --collector.process.iis --collector.process.counter-version=2"

Configure Firewall

netsh advfirewall firewall add rule name="Allow Windows Exporter" dir=in action=allow protocol=TCP localport=9100

Verify

sc query windows_exporter

Add to Monitoring

After Windows Exporter is running, add the server to servers.env on the Prometheus server:

# === IIS SERVERS ===
NODE <server-ip>:9100 <Prefix>-IIS service=IIS,role=webserver,node_ip=<server-ip>

Then regenerate targets:

cd /opt/grafana/prometheus-grafana-stack/prometheus
/opt/grafana/prometheus-grafana-stack/prometheus/scripts/generate-targets.sh

Note: Windows Exporter uses the NODE type since it exposes metrics on the same port (9100) in a compatible format. The service=IIS label is used by the Windows/IIS Grafana dashboard for filtering.


8. Configure and Deploy Prometheus with Grafana

Target server: db (or separate server dedicated for Grafana only)

In this example, we are using the db server to host Grafana and Prometheus, but there is no strict requirement—it can be any server that suits your environment.

Hardware requirements: For small-scale setups (up to 10 server nodes), 4 vCPU and 8 GB RAM is sufficient. For larger deployments, see Grafana sizing guidelines.

Use the one-command bootstrap:

curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

On the interactive path, bootstrap.sh stack opens a full-screen arrow-key configuration menu (↑↓ + Enter to navigate, Esc to go back, per-item help panel, masked passwords; nothing is written until Apply):

Menu section What it configures

Server

SERVER_IP, access domain (GRAFANA_DOMAIN — also the TLS cert CN)

Prometheus

port, data retention, basic-auth user/password (protects the Prometheus/Alertmanager UIs)

Grafana

port, admin user/password, and the shared SMTP (host/from/user/password) used by both Grafana and alert email

FTACS Database

MySQL/Oracle type + host/port/user/password/name for the Business dashboard (empty host = skip)

SSL / HTTPS

self-signed (validity/org), Let’s Encrypt (email), or an existing certificate (paths shown)

Alerting

drill into any channel to configure it — Email, Telegram (live chat auto-detect), Slack, Teams, Webhook, SMS, SNMP (v2c/v3)

UI Portal integration

UI_PORTAL_ORIGINS + extra allowed networks for /grafana-ro/ embedding

Review & apply

shows every value, then writes .env and applies

After Apply the script deploys the stack (containers up), auto-registers this host’s exporters in servers.env and generates targets, creates the read-only Grafana service token, and — on an existing install — changes the admin password via grafana cli if you set a new one (no current password needed). Values live in /opt/grafana/prometheus-grafana-stack/.env.

Run with -y to skip the menu entirely (non-interactive: domain = server IP, random passwords, self-signed cert).

After deployment:

  1. Configure Prometheus targets (see section 8.1 below)

  2. Verify /grafana-ro/ works if you configured UI Portal Origins in phase 5

Manual Installation (click to expand)

  1. Clone or download this repo into /opt/grafana, as described previously.

  2. Configure environment variables:

    Navigate to the Prometheus-Grafana stack directory and create the .env file:

    cd /opt/grafana/prometheus-grafana-stack
    cp .env.example .env
    vi .env

    Update the following REQUIRED variables:

    # Server Configuration (REQUIRED)
    SERVER_IP=65.109.58.165  # Replace with your server's IP
    
    # Grafana Admin Credentials (REQUIRED)
    # NOTE: Password in .env is used only on FIRST deployment.
    # After that, database is the source of truth.
    # To change password later, use: docker exec grafana grafana cli admin reset-admin-password NEW_PASS
    GRAFANA_ADMIN_USER=admin
    GRAFANA_ADMIN_PASSWORD=your_secure_password_here
    
    # SMTP Configuration (REQUIRED for alerting)
    SMTP_ENABLED=true
    SMTP_HOST=smtp.example.com:587
    SMTP_USER=your_smtp_user
    SMTP_PASSWORD=your_smtp_password
    
    # Grafana Service Token (REQUIRED for nginx proxy)
    GRAFANA_SERVICE_TOKEN=your_token_here  # Will be generated in section 8.2

    Optional variables (can be left as default):

    • PROMETHEUS_VERSION, GRAFANA_VERSION, NGINX_VERSION

    • PROMETHEUS_PORT=9090 — Prometheus’s own listen port. GRAFANA_PORT=443 — the external HTTPS port nginx listens on for Grafana, not Grafana’s own port (Grafana listens on 127.0.0.1:3100, hardcoded in docker-compose.yml; there is no variable for it)

    • PROMETHEUS_RETENTION=90d

    • GRAFANA_PLUGINS - Comma-separated list of plugins

      + Full environment variables reference

      +

Variable

Description

Required

SERVER_IP

Your server’s external IP address or hostname

✅ Yes

GRAFANA_ADMIN_USER

Admin username for Grafana authentication

✅ Yes

GRAFANA_ADMIN_PASSWORD

Admin password for Grafana authentication

✅ Yes

SMTP_HOST

SMTP server address and port for email alerts (e.g., smtp.gmail.com:587)

✅ Yes

SMTP_USER

SMTP username (usually an email address)

✅ Yes

SMTP_PASSWORD

SMTP password or API key

✅ Yes

GRAFANA_SERVICE_TOKEN

Service account token for nginx proxy (generated in section 8.2)

✅ Yes

PROMETHEUS_RETENTION

Data retention period (default: 90d)

❌ No

PROMETHEUS_PORT

Prometheus port (default: 9090)

❌ No

GRAFANA_PORT

External HTTPS port nginx listens on for Grafana (default: 443) — not Grafana’s own port, which is hardcoded to 127.0.0.1:3100 and has no variable

❌ No

+ For a complete list of all variables, see .env.example in each directory.

+ Grafana Internal Environment Variables Reference:

+ These variables are set automatically by docker-compose based on your .env file:

+

Variable Description

GF_SERVER_ROOT_URL

Root URL of Grafana instance, used to generate correct links in the UI

GF_SERVER_ALLOW_ORIGIN

Allowed origin for CORS (Cross-Origin Resource Sharing)

GF_LIVE_ALLOWED_ORIGINS

Allowed origins for WebSocket connections (Grafana Live)

GF_SECURITY_ADMIN_USER

Admin username for Grafana authentication

GF_SECURITY_ADMIN_PASSWORD

Admin password for Grafana authentication

GF_SMTP_ENABLED

Enables SMTP email notifications (true = enabled)

GF_SMTP_HOST

SMTP server address and port for sending alerts

GF_SMTP_USER

SMTP username (usually an email address)

GF_SMTP_PASSWORD

SMTP password or API key

GF_SECURITY_ALLOW_EMBEDDING

Allows embedding dashboards in iframes (true = enabled)

GF_DASHBOARDS_JSON_ENABLED

Enables JSON-based dashboard provisioning

GF_DASHBOARDS_CUSTOM_CSS

Custom CSS file path for UI styling

+ . Prepare generated files (the bootstrap script creates these automatically — a manual/offline installation must create them before the first start, otherwise Docker turns the missing bind-mount files into directories and the containers fail with “Are you trying to mount a directory onto a file”):

+

cd /opt/grafana/prometheus-grafana-stack

# Alertmanager config placeholder (regenerated by the container entrypoint)
cat > alertmanager/alertmanager.yml << 'EOF'
# Placeholder - will be regenerated by alertmanager entrypoint.sh
global:
  resolve_timeout: 5m
route:
  receiver: 'default'
receivers:
  - name: 'default'
EOF
chmod 666 alertmanager/alertmanager.yml

# TLS certificate for the nginx proxy (self-signed; replace with a real
# certificate later if you have one — files: ssl/certificate.crt, ssl/private.key).
# ssl/ is git-ignored, so it is NOT in the ZIP — create it first.
mkdir -p ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout ssl/private.key -out ssl/certificate.crt \
  -subj "/C=US/ST=State/L=City/O=FriendlyTech/CN=${GRAFANA_DOMAIN:-$(hostname -f)}"

# Prometheus basic-auth file for nginx (user: prometheus). Also git-ignored, so
# nginx would otherwise mount a directory onto it and fail. htpasswd may be
# absent on RHEL — fall back to openssl apr1; write the password back into .env.
PROM_PASS=$(openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c16)
if command -v htpasswd >/dev/null 2>&1; then
  htpasswd -bc .htpasswd prometheus "$PROM_PASS"
else
  echo "prometheus:$(openssl passwd -apr1 "$PROM_PASS")" > .htpasswd
fi
grep -q '^PROMETHEUS_PASSWORD=' .env \
  && sed -i "s/^PROMETHEUS_PASSWORD=.*/PROMETHEUS_PASSWORD=$PROM_PASS/" .env \
  || echo "PROMETHEUS_PASSWORD=$PROM_PASS" >> .env
echo "Prometheus UI login: prometheus / $PROM_PASS"

# nginx ACME webroot (bind-mounted; keep it a directory)
mkdir -p acme-challenge

Also make sure GRAFANA_DOMAIN in .env is set to the server’s domain or IP — nginx serves Grafana only for that hostname. . Start the Prometheus-Grafana stack:

+

cd /opt/grafana/prometheus-grafana-stack
docker compose up -d
  1. Enable access to Grafana and Prometheus ports:

    iptables -A INPUT -p tcp --dport 80 -j ACCEPT
    iptables -A INPUT -p tcp --dport 443 -j ACCEPT
    # If server has IPv6 address, also add ip6tables rules:
    ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
    ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT

    Note: Ports 9090 (Prometheus) and 3100 (Grafana) bind to 127.0.0.1 and are only accessible through nginx reverse proxy. No need to open them in firewall.

  2. Test the setup:

  3. Configure Alerting (optional):

    Update the email in /opt/grafana/prometheus-grafana-stack/grafana/provisioning/alerting/alert_resources.yaml with your email(s). Multiple emails can be separated by ;, \n, or ,.


Accessing Web Interfaces

After deployment, the monitoring stack provides two web interfaces accessible via nginx reverse proxy:

Service URL Username Password

Grafana

https://<domain>/

admin

.envGRAFANA_ADMIN_PASSWORD

Prometheus

https://<domain>/prometheus

prometheus

.envPROMETHEUS_PASSWORD

Note: Both services bind to 127.0.0.1 and are only accessible through nginx. Direct access via IP:3100 or IP:9090 is not available.

Note: The “Business metrics” dashboard requires connection to your FTACS application database (MySQL or Oracle) for tenant/domain lists. Configure FTACS_DB_* variables in .env (set FTACS_DB_TYPE to mysql or oracle) — this is the ACS database, not the monitoring database.

How it works:

All external traffic goes through nginx reverse proxy (ports 80/443). Nginx handles SSL, authentication, and forwards requests to internal services:

Browser → https://<domain>/          → nginx (port 443) → Grafana (127.0.0.1:3100)
Browser → https://<domain>/prometheus → nginx (port 443) → Prometheus (127.0.0.1:9090)

Grafana and Prometheus listen only on 127.0.0.1 (localhost) — they are not reachable directly from outside. This is why only ports 80 and 443 need to be opened in the firewall, not 3100 or 9090.

How to find your passwords:

cd /opt/grafana/prometheus-grafana-stack

# View Grafana password
grep GRAFANA_ADMIN_PASSWORD .env

# View Prometheus password
grep PROMETHEUS_PASSWORD .env

# Reset Grafana password if forgotten
docker exec grafana grafana cli admin reset-admin-password NEW_PASSWORD

Grafana UI pages:

Prometheus UI pages:


8.1 Configure Prometheus Targets

After the stack is running, configure which servers Prometheus should monitor.

Tip: When you deploy exporters using quick-deploy.sh or bootstrap.sh, the script outputs ready-to-use configuration lines for servers.env. Just copy and paste them.

The stack server registers itself. Re-running bootstrap.sh stack appends this host’s own exporters if they are not listed yet, so it is safe to run repeatedly. NODE, PROCESS and CADVISOR describe the machine, so there is exactly one of each per host: an entry already counts as present if either its address or its instance name matches. That matters because both can change under you — a new NIC or DHCP lease moves the address, and a different INSTANCE_PREFIX renames the instance. Matching on one key alone would let the other change through as a brand-new target, and the host would be scraped twice under two names, doubling every host-level panel. Nothing is ever rewritten in place: when the existing line disagrees with what was just detected, bootstrap prints both spellings and leaves the file alone, e.g.

⚠ servers.env: NODE already listed as '10.0.0.30:9100 DB-MySQL' for this host
  (detected: 10.0.0.30:9100 stack-DB-MySQL) — not duplicating; edit the line if it is out of date

Edit the line yourself if the old value is wrong, then re-run generate-targets.sh.

Step 1: Edit the server inventory

cd /opt/grafana/prometheus-grafana-stack/prometheus/targets
vi servers.env

First time setup:

  1. Run /opt/grafana/prometheus-grafana-stack/prometheus/scripts/generate-targets.sh — creates servers.env from template

  2. Edit servers.env — add your servers

  3. Run /opt/grafana/prometheus-grafana-stack/prometheus/scripts/generate-targets.sh again — generates JSON target files

Your servers.env is git-ignored and won’t be overwritten by repository updates.

Format: TYPE IP:PORT INSTANCE [EXTRA_LABELS]

Field Description Example

TYPE

Exporter type (see supported types below)

NODE, MYSQL, JMX_HIKARI

IP:PORT

Server IP and exporter port

65.109.58.165:9100

INSTANCE

Unique instance name for Grafana

DevOps-DB, Prod-ACS1

EXTRA_LABELS

Optional comma-separated Prometheus labels

service=mysql,role=database

EXTRA_LABELS are additional labels attached to all metrics from this target. Useful for:

  • Filtering in Grafana dashboards (service=mysql)

  • Grouping servers by role (role=database, role=application)

  • Environment tagging (env=prod, env=staging)

Instance Naming Convention (recommended):

Instance names are flexible - you can use any naming scheme that works for your organization. The recommended format is {Environment}-{ServerType}{Number}:

Prefix Server Role Examples

ACS

Application servers (WildFly/FTACS)

DevOps-ACS1, Prod-ACS2

DB

Database servers (MySQL, Oracle)

DevOps-DB, Prod-OracleDB

HC

Hazelcast cache servers (standalone)

DevOps-HC1, Prod-HC

API

Microservices (Provision, Northbound, Service, Subscription)

DevOps-APIs, Prod-API1

IIS

Windows/IIS web servers

DevOps-IIS, Prod-IIS1

💡 Tip: Use the role label for server grouping: role=database, role=application, role=cache, role=api, role=webserver, role=monitoring

Which exporters for which server type:

Server Type Required Exporters Optional Exporters

Any server

NODE, PROCESS, CADVISOR

-

MySQL DB

MYSQL

-

Oracle DB

ORACLE

-

ClickHouse

CLICKHOUSE

-

PostgreSQL

POSTGRES

-

WildFly/ACS

JMX_HIKARI, JMX_JVM, JMX_HC

ACS_METRICS, FT_SYSTEM

Hazelcast (standalone)

JMX_HC (native Prometheus)

-

Angular UI (HC1)

NGINX_PORTALS, FT_DEVICE_NETWORK, AI_AGENT

UI_BACKEND (requires actuator)

API servers

PROVISION_API, NORTHBOUND_API, SERVICE_API

-

FTACS servers

SUBSCRIPTION_API (native on port 8080)

-

Windows/IIS

NODE (Windows Exporter on port 9100)

-

Minimal example (one server of each type):

# MySQL database server
NODE 10.0.0.10:9100 Prod-DB
PROCESS 10.0.0.10:9256 Prod-DB
CADVISOR 10.0.0.10:9183 Prod-DB
MYSQL 10.0.0.10:9104 Prod-DB

# WildFly/ACS application server
NODE 10.0.0.20:9100 Prod-ACS1
PROCESS 10.0.0.20:9256 Prod-ACS1
CADVISOR 10.0.0.20:9183 Prod-ACS1
JMX_HIKARI 10.0.0.20:5556 Prod-ACS1
JMX_JVM 10.0.0.20:5557 Prod-ACS1

# Hazelcast cache server (standalone)
NODE 10.0.0.30:9100 Prod-HC1 role=cache
PROCESS 10.0.0.30:9256 Prod-HC1
CADVISOR 10.0.0.30:9183 Prod-HC1
JMX_HC 10.0.0.30:9101 Prod-HC1

# API server (microservices)
NODE 10.0.0.35:9100 Prod-API1 role=api
PROCESS 10.0.0.35:9256 Prod-API1
CADVISOR 10.0.0.35:9183 Prod-API1
PROVISION_API 10.0.0.35:8091 Prod-API1
NORTHBOUND_API 10.0.0.35:9880 Prod-API1
SERVICE_API 10.0.0.35:8085 Prod-API1
SUBSCRIPTION_API 10.0.0.35:8080 Prod-API1

# Oracle database server
NODE 10.0.0.40:9100 Prod-OracleDB
PROCESS 10.0.0.40:9256 Prod-OracleDB
CADVISOR 10.0.0.40:9183 Prod-OracleDB
ORACLE 10.0.0.40:9161 Prod-OracleDB

# Windows/IIS web server
NODE 10.0.0.50:9100 Prod-IIS service=IIS,role=webserver

Full example with labels and API endpoints

# === DATABASE SERVERS ===
NODE 65.109.58.165:9100 DevOps-DB service=mysql,role=database
PROCESS 65.109.58.165:9256 DevOps-DB
CADVISOR 65.109.58.165:9183 DevOps-DB
MYSQL 65.109.58.165:9104 DevOps-DB

# === ACS SERVERS ===
NODE 65.109.58.164:9100 DevOps-ACS1 service=ACS,role=application
PROCESS 65.109.58.164:9256 DevOps-ACS1
CADVISOR 65.109.58.164:9183 DevOps-ACS1
JMX_HIKARI 65.109.58.164:5556 DevOps-ACS1
JMX_JVM 65.109.58.164:5557 DevOps-ACS1
# Optional ACS endpoints:
FT_SYSTEM 65.109.58.164:8090 DevOps-ACS1
ACS_METRICS 65.109.58.164:8080 DevOps-ACS1
JMX_HC 65.109.58.164:9101 DevOps-ACS1

# === HAZELCAST SERVERS (standalone) ===
NODE 65.109.24.154:9100 DevOps-HC1 service=hazelcast,role=cache
PROCESS 65.109.24.154:9256 DevOps-HC1
CADVISOR 65.109.24.154:9183 DevOps-HC1
JMX_HC 65.109.24.154:9101 DevOps-HC1

# === API SERVERS (microservices) ===
NODE 65.109.24.155:9100 DevOps-API1 service=api,role=api
PROCESS 65.109.24.155:9256 DevOps-API1
CADVISOR 65.109.24.155:9183 DevOps-API1
PROVISION_API 65.109.24.155:8091 DevOps-API1
NORTHBOUND_API 65.109.24.155:9880 DevOps-API1
SERVICE_API 65.109.24.155:8085 DevOps-API1
SUBSCRIPTION_API 65.109.24.155:8080 DevOps-API1

# === ORACLE SERVERS ===
NODE 65.109.20.174:9100 DevOps-OracleDB service=oracle,role=database
PROCESS 65.109.20.174:9256 DevOps-OracleDB
CADVISOR 65.109.20.174:9183 DevOps-OracleDB
ORACLE 65.109.20.174:9161 DevOps-OracleDB

# === IIS SERVERS ===
NODE 65.109.49.150:9100 devops-ui.friendly-tech.com service=IIS,role=webserver,node_ip=65.109.49.150

Step 2: Generate Prometheus target files

cd /opt/grafana/prometheus-grafana-stack/prometheus
/opt/grafana/prometheus-grafana-stack/prometheus/scripts/generate-targets.sh

The script automatically:

  • ✅ Parses servers.env

  • ✅ Generates JSON files in targets/ folder (one per exporter type)

  • ✅ Adds exporter_type label to each target (e.g., node, hazelcast, hikari)

  • ✅ Validates JSON syntax

  • ✅ Checks target reachability

  • ✅ Shows summary of generated targets

Note: The script validates all entries before generating targets. If there are format errors (wrong TYPE, missing instance name, labels in instance field), it will show the exact line, what’s wrong, and how to fix it.

How it works: prometheus.yml uses file_sd_configs to read JSON files from targets/ folder. Prometheus auto-reloads within 30 seconds — no restart needed!

Adding new servers later:

  1. Edit servers.env — add new server lines

  2. Run /opt/grafana/prometheus-grafana-stack/prometheus/scripts/generate-targets.sh

  3. Done! Prometheus picks up changes automatically

Benefits:

  • Zero downtime - No Prometheus restart required

  • 🛡️ Error-proof - Cannot break YAML syntax

  • 📝 Simple format - Easy to read and edit

  • 🔄 Auto-reload - Prometheus picks up changes automatically

  • 🔒 Safe updates - Your servers.env is git-ignored and won’t be overwritten

Supported target types (TYPE field reference)

The TYPE field in servers.env determines which Prometheus job will scrape the target. Each type maps to a specific exporter and port:

TYPE Port Description Prometheus Job exporter_type

NODE

9100

System metrics (CPU, memory, disk)

node-exporter

node

PROCESS

9256

Host process metrics

process-exporter

process

CADVISOR

9183

Docker container metrics

cadvisor

cadvisor

MYSQL

9104

MySQL/MariaDB database metrics

mysql

mysql

ORACLE

9161

Oracle database metrics

oracledb

oracle

CLICKHOUSE

9363

ClickHouse database metrics (native endpoint)

clickhouse

clickhouse

POSTGRES

9187

PostgreSQL database metrics

postgres

postgres

NGINX_PORTALS

9113

Nginx web server metrics (Angular UI)

nginx

nginx

JMX_HIKARI

5556

HikariCP connection pool (WildFly)

JmxExporter

hikari

JMX_JVM

5557

JVM heap, GC, threads (WildFly)

JmxExporter

jvm

JMX_HC

9101

Hazelcast: native endpoint (embedded in ACS) or the jmx-hazelcast exporter (standalone/docker, JMX :9110 → :9101)

JmxExporter

hazelcast

FT_SYSTEM

8090

FT System Spring Boot metrics (WildFly)

ft-system-metrics

ft-system

ACS_METRICS

8080

ACS application metrics

acs-metrics

acs-metrics

FT_DEVICE_NETWORK

8383

FT Device Network Service (Actuator)

ft-device-network

ft-device-network

AI_AGENT

8084

AI Agent Python/FastAPI metrics

ai-agent

ai-agent

UI_BACKEND

8881

UI Backend Spring Boot Actuator

ui-backend

ui-backend

PROVISION_API

8091

Provision Portal API metrics

provision-api

provision-api

NORTHBOUND_API

9880

Northbound API metrics

northbound-api

northbound-api

SERVICE_API

8085

Service API metrics

service-api

service-api

SUBSCRIPTION_API

8080

Subscription API metrics

subscription-api

subscription-api

Parking a target that stopped answering. generate-targets.sh probes every row and reports the ones that did not respond. Two flags act on that list — neither ever deletes a line, because a row records an address somebody added for a reason and deleting it takes the reason with it:

# show what did not answer this run; changes nothing
bash /opt/grafana/prometheus-grafana-stack/prometheus/scripts/generate-targets.sh --park-dry-run

# comment those rows out, keeping the original line, with a backup
bash /opt/grafana/prometheus-grafana-stack/prometheus/scripts/generate-targets.sh --park

A parked row looks like this, and comes back by deleting the three #:

# PARKED 2026-08-05 - did not answer when targets were last generated
# JMX_HC 135.181.62.187:9101 ACS-qa12m
# -> uncomment when JMX_HC answers again at 135.181.62.187:9101

Parking never happens on its own. A target that is merely restarting reads as unreachable too, so --park has to be typed by someone who has looked at the dry-run list. A row that answered is never touched.

Metrics paths. Most targets are scraped on /metrics, but the Spring Boot services are not, and the path is not guessable from the service name. These are the paths the generator writes into metrics_path — use them when probing a target by hand:

  • NORTHBOUND_API and SERVICE_API/iot-webservice/actuator/prometheus

  • PROVISION_API/prov-portal/actuator/prometheus

  • FT_DEVICE_NETWORK/api/actuator/prometheus

  • SUBSCRIPTION_API/rest/subscription-metrics (ACS 6.4 only; 6.5+ serves these through ACS_METRICS instead, and the generator skips the target when the endpoint answers without metrics)

  • FT_SYSTEM, UI_BACKEND, AI_AGENT, ACS_METRICS/actuator/prometheus

So a target that looks down is worth re-checking on its own path before anything else:

curl -s -o /dev/null -w '%{http_code}\n' \
  http://<ip>:8091/prov-portal/actuator/prometheus     # not /metrics

Note: The port in the table is the default. Always specify the actual port in your servers.env line: TYPE IP:PORT INSTANCE

How IP addresses appear in Grafana dashboards

No configuration neededprometheus.yml already includes relabel_configs that extract IP addresses from target addresses and store them in the node_ip label.

Grafana dashboards use this label to display server IPs in tables and filters.

How it works (FYI):

# prometheus.yml already has this for each job:
relabel_configs:
  - source_labels: [__address__]
    target_label: node_ip
    regex: (.+):.*      # extracts IP from "IP:PORT"
    replacement: $1

8.2 Configure Nginx Proxy for Embedding

Enable a read-only reverse proxy for embedding Grafana dashboards in external applications (e.g., Angular UI portal).

The /grafana-ro/ endpoint requires two things configured in .env:

  • GRAFANA_SERVICE_TOKEN — Grafana service account token (auto-generated by bootstrap, or create manually — see below)

  • UI_PORTAL_ORIGINS — comma-separated list of portal URLs allowed to access /grafana-ro/ (IP whitelist)

Step 1: Configure allowed portal origins

Set UI_PORTAL_ORIGINS in .env to the URLs of applications that will embed Grafana:

cd /opt/grafana/prometheus-grafana-stack
vi .env

# Example: allow access from Angular UI portal
UI_PORTAL_ORIGINS=https://portal.example.com,https://10.0.0.50:8880

Without this setting, /grafana-ro/ returns 403 Forbidden for all external requests.

Step 2: Verify service token

Check that GRAFANA_SERVICE_TOKEN is set (not CHANGE_ME):

grep GRAFANA_SERVICE_TOKEN .env

If it shows CHANGE_ME, generate a token — see “Manual token generation” below.

Step 3: Restart containers

docker compose down && docker compose up -d

Test the Nginx reverse proxy:

Open https://grafana_domain/grafana-ro/ — you should see Grafana dashboards without needing to log in, with read-only permissions.

Manual token generation (if bootstrap failed or manual installation)

Run the automated script:

cd /opt/grafana
sudo bash /opt/grafana/misc/generate-grafana-token.sh --restart

The script creates a Grafana service account (ReadOnlySA) with Viewer role, generates a token, and updates .env automatically.

If the script fails, generate the token manually:

  1. Open Grafana: https:/// (Grafana binds to 127.0.0.1 only — it is reachable through nginx, not directly via the server IP)

  2. Go to: AdministrationUsers and accessService accounts

  3. Click “Add service account” → Name: ReadOnlySA, Role: ViewerAdd

  4. Click “Add service account token” → Name: nginx_roCopy the token

  5. Update .env:

    cd /opt/grafana/prometheus-grafana-stack
    vi .env
    # Set: GRAFANA_SERVICE_TOKEN=glsa_YourActualTokenHere_12345678
  6. Restart: docker compose down && docker compose up -d

How nginx proxy works

The nginx container uses a template file (nginx.conf.template) with ${GRAFANA_SERVICE_TOKEN} placeholder. On startup, the entrypoint script:

  1. Reads the template

  2. Replaces placeholder with value from .env

  3. Generates final nginx.conf

  4. Starts nginx

All services run in network_mode: host. Nginx connects to Grafana via http://127.0.0.1:3100 and serves HTTPS on ports 80/443.

8.3 Embedding Dashboards in iframe

The /grafana-ro/ path provides read-only access without authentication, making it perfect for embedding dashboards in external applications.

How to get dashboard URL for embedding:

  1. Open the dashboard in Grafana (via https://<your-domain>/, with authentication)

  2. Copy the dashboard path (e.g., /d/abc123/my-dashboard)

  3. Add parameters:

    • ?orgId=1 - Organization ID (required)

    • &kiosk - Kiosk mode (hides Grafana menus)

    • &refresh=30s - Auto-refresh interval (optional)

Example iframe code:

<iframe
  src="/d/business-metrics/business-metrics?orgId=1&kiosk&refresh=30s"
  width="100%"
  height="600"
  frameborder="0">
</iframe>

Available kiosk modes:

  • &kiosk - Full kiosk mode (no top nav, no side menu)

  • &kiosk=tv - TV mode (hides top nav, shows only dashboard)

Common parameters:

  • &from=now-6h&to=now - Time range

  • &var-instance=ServerName - Dashboard variable values

  • &theme=dark - Dark theme

  • &theme=light - Light theme

Important notes:

  • Do NOT use “Share externally” button in Grafana UI (requires additional permissions)

  • Service account with Viewer role cannot create shared dashboards

  • Always use direct dashboard URLs through /grafana-ro/ path

  • Test the URL in browser before embedding to ensure it works

Verify nginx proxy is working:

# Should return 200 OK without authentication
curl -kI https://YOUR_SERVER_DOMAIN/grafana-ro/

9. Managing Grafana Dashboards Provisioning

By default, Grafana automatically provisions all dashboards located under:

/opt/grafana/prometheus-grafana-stack/grafana/provisioning/dashboards/definitions/

Sometimes it is not necessary (or desirable) to install all dashboards at once. You have two options:

9.1 Skip Dashboards During Installation

  1. Before running the stack, remove or move unwanted dashboard JSON files from the definitions folder:

mkdir -p /opt/grafana/prometheus-grafana-stack/grafana/provisioning/dashboards/disabled
mv /opt/grafana/prometheus-grafana-stack/grafana/provisioning/dashboards/definitions/<dashboard-to-skip>.json \
/opt/grafana/prometheus-grafana-stack/grafana/provisioning/dashboards/disabled/

Grafana will only load dashboards present in the definitions folder during startup.

  1. Start the stack as usual:

cd /opt/grafana/prometheus-grafana-stack
docker compose up -d

9.2 Add Dashboards Later

If later you decide to enable some dashboards:

  1. Move the desired JSON file(s) back into the definitions folder:

mv /opt/grafana/prometheus-grafana-stack/grafana/provisioning/dashboards/disabled/<dashboard>.json \
/opt/grafana/prometheus-grafana-stack/grafana/provisioning/dashboards/definitions/
  1. Restart Grafana container to apply changes:

cd /opt/grafana/prometheus-grafana-stack
docker compose restart grafana

Tip: You don’t need to restart the whole stack, only the Grafana container.


10. Alertmanager Configuration

Alertmanager handles alert notifications from Prometheus. It supports multiple notification channels.

API Documentation: See Alertmanager API Guide for application integration (send alerts, manage silences, query status).

SNMP Integration: See SNMP Notifier Guide for NMS integration (custom OIDs, MIB file, SNMPv3 configuration).

Supported Channels

Channel Required Settings Description

Email

SMTP + ALERT_EMAIL

Uses SMTP settings from Grafana configuration

Telegram

Bot Token + Chat ID

Auto-detection supported

Slack

Webhook URL

Incoming Webhook from Slack App

MS Teams

Webhook URL

Power Automate Workflows

Webhook

URL

Custom HTTP endpoint

SMS

Gateway URL + Token

HTTP gateway (Twilio, Vonage)

SNMP

Trap Address + Version

SNMP v2c/v3 traps to NMS

Interactive Setup

Alertmanager is configured during stack deployment:

curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

# Configure alert notifications? [y/N]: y
# Select channels: 1,2  (Email + Telegram)

The bootstrap script will:

  • Guide you through each channel configuration

  • Offer testing options (MailPit for email, SNMP Trap Catcher for SNMP)

  • Auto-detect Telegram chat_id when possible

  • Validate input format

  • Save settings to .env

Manual Configuration

Edit .env and restart:

cd /opt/grafana/prometheus-grafana-stack
vi .env

Email

ALERT_EMAIL=alerts@company.com

During bootstrap, you have 3 options for Email configuration:

  1. Use Grafana SMTP settings - Uses SMTP already configured for Grafana

  2. Configure new SMTP settings - Enter custom SMTP host, user, password

  3. Use MailPit for testing - Auto-configures fake SMTP (no real emails sent)

Testing with MailPit (fake SMTP):

# Start MailPit (auto-started when option 3 is selected)
docker compose --profile testing up -d mailpit

# Web UI to view emails (opens automatically in bootstrap output)
open http://<server-ip>:8025
  • MailPit captures all emails without sending them

  • Modern HTML email templates with severity colors

  • View email history in web interface

Telegram

TELEGRAM_BOT_TOKEN=123456789:ABCdefGHI...
TELEGRAM_CHAT_ID=-1001234567890

Slack

SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00/B00/XXXX
SLACK_CHANNEL=#alerts

MS Teams

MSTEAMS_WEBHOOK_URL=https://outlook.office.com/webhook/...

Custom Webhook

APP_WEBHOOK_URL=https://your-app.com/api/alerts

SMS Gateway

SMS_GATEWAY_URL=https://api.twilio.com/...
SMS_API_TOKEN=your-token

SNMP Traps

During bootstrap, you have 2 options for SNMP configuration:

  1. Configure external NMS - Enter NMS address, SNMP version, community/credentials

  2. Use SNMP Trap Catcher for testing - Auto-configures local trap receiver with web UI

# SNMPv2c
SNMP_TRAP_ADDRESS=nms.company.com:162
SNMP_VERSION=V2c
SNMP_COMMUNITY=public

# SNMPv3 (authentication + encryption)
SNMP_VERSION=V3
SNMP_AUTH_USERNAME=snmpuser
SNMP_AUTH_PASSWORD=authpassword
SNMP_PRIV_PASSWORD=privpassword

Testing with SNMP Trap Catcher:

# Start SNMP services (auto-started when option 2 is selected)
docker compose --profile snmp up -d

# Web UI to view traps (opens automatically in bootstrap output)
open http://<server-ip>:8089
  • Captures all SNMP traps locally

  • Real-time web interface showing trap history

  • Supports SNMPv2c (for testing)

SNMP Documentation:

  • SNMP Notifier Guide — full setup guide, OID tables per protocol (TR-069, USP, LWM2M), SNMPv3 configuration, device limit alarms, infrastructure resource alarms

  • Architecture: SNMP Trap Flow — diagram showing Alert → Alertmanager → snmp-notifier → SNMP trap → NMS, component descriptions, OID structure

  • OID Mapping Reference — complete OID-to-alert mapping table with numeric OIDs, alert names, and descriptions

  • MIB File — ASN.1 MIB definition (FRIENDLY-TECH-ACS-MIB v2.0.0) for import into NMS systems; on an installed server: /opt/grafana/prometheus-grafana-stack/alertmanager/mibs/FRIENDLY-TECH-ACS-MIB.txt

  • PROD-468 Implementation Report — detailed report of granular OID mapping implementation, testing results, acceptance criteria coverage

  • Thresholds UIhttps://<domain>/alertmanager-config/thresholds — web interface for editing alert thresholds without restart (auto-discovers all configurable alerts)

  • Alert Rules Editorhttps://<domain>/alertmanager-config/alerts — web interface for full CRUD on Prometheus alert rules (add/edit/delete alerts with OID labels)

Apply Changes
docker compose restart alertmanager

Web Interface

Testing Services

If you selected testing options during bootstrap, these UIs are available:

Service URL Purpose

MailPit

http://<server>:8025

View captured emails

SNMP Trap Catcher

http://<server>:8089

View captured SNMP traps

Testing Notification Channels

When you send a test alert to Alertmanager, it routes to ALL configured channels:

Test Alert → Alertmanager (9093) → Email (MailPit if testing)
                                 → Telegram
                                 → Slack
                                 → MS Teams
                                 → Webhook
                                 → SNMP Notifier (9464) → NMS / Trap Catcher (162)
Step 1: Send Test Alert
curl -X POST http://localhost:9093/api/v2/alerts \
  -H "Content-Type: application/json" \
  -d '[{
    "labels": {"alertname": "TestAlert", "severity": "warning", "instance": "test"},
    "annotations": {"summary": "Test alert - please ignore"}
  }]'
Step 2: Verify Each Channel
Channel How to Verify Expected Result

Email

Check inbox of ALERT_EMAIL

Email with alert details

Telegram

Check configured chat/group

Message with 🟠 WARNING

Slack

Check configured channel

Message in #alerts

MS Teams

Check configured channel

Adaptive Card message

Webhook

Check your app logs

POST request with JSON

SNMP

Check NMS or snmp-catcher

Trap with OID 1.3.6.1.4.1.65666.2.0.99

SNMP Testing
# 1. Check SNMP services are running
docker ps | grep -E "snmp-notifier|snmp-catcher"

# 2. Send test alert
curl -X POST http://localhost:9093/api/v2/alerts \
  -H "Content-Type: application/json" \
  -d '[{"labels":{"alertname":"DbUnreachable","severity":"critical"}}]'

# 3. Check snmp-notifier logs
docker logs snmp-notifier --tail 10

# 4. View traps in web UI (if using SNMP Trap Catcher)
open http://<server>:8089

SNMP Trap Catcher features:

  • Web UI at port 8089 showing all received traps

  • Real-time updates

  • Trap details: timestamp, OID, variables, source IP


11. Downgrade / Rollback

Bootstrap Command Reference

# Show help
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --help

# Show available versions
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --version-list

Installing a Previous Version

To install a specific version, use the --branch parameter:

curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

⚠️ WARNING: When specifying --branch, you are installing a specific version instead of the latest stable release (main). Make sure this is intentional.

Before Downgrade

  1. Check current version:

    cat /opt/grafana/VERSION
  2. List available versions:

    curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --version-list
  3. Backup data (recommended):

    cd /opt/grafana/prometheus-grafana-stack
    docker compose down
    cp -r prometheus_data /backup/prometheus_data_$(date +%Y%m%d)
    cp -r grafana_data /backup/grafana_data_$(date +%Y%m%d)

Important Notes

  • Data compatibility: Downgrading may cause issues if Prometheus or Grafana database schemas changed between versions

  • Dashboards: Custom dashboards created in newer version may not work in older version

  • Configuration: .env settings are preserved, but new options added in later versions will be ignored

Clean Downgrade (Full Reset)

If you experience issues after downgrade or need a guaranteed clean state, perform a full reset. This involves two parts:

  1. Stack cleanup (Prometheus/Grafana server) - removes metrics history, dashboards, alert states

  2. Exporters cleanup (target nodes: ACS, MySQL, etc.) - removes exporter containers


Part 1: Stack (Prometheus/Grafana Server)

Step 1: Stop all services

cd /opt/grafana/prometheus-grafana-stack
docker compose down

Step 2: Backup current data (optional)

mkdir -p /backup/grafana-$(date +%Y%m%d)
cp -r prometheus_data grafana_data alertmanager_data /backup/grafana-$(date +%Y%m%d)/
cp .env /backup/grafana-$(date +%Y%m%d)/

Step 3: Remove data directories

cd /opt/grafana/prometheus-grafana-stack
rm -rf prometheus_data grafana_data alertmanager_data

Step 4: Remove repository and reinstall

rm -rf /opt/grafana

# Install latest stable version
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

# Or install specific version (downgrade)
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

Note: Without --branch, the latest stable version from main is installed. Use --branch X.Y.Z only when you need a specific older version.

What gets removed:

Data Location Effect

Prometheus metrics

prometheus_data/

All historical metrics lost

Grafana database

grafana_data/

Dashboards, users, API keys reset

Alertmanager state

alertmanager_data/

Silences, notification history reset

What is preserved:

Data Location Notes

Configuration

.env

Backed up in Step 2, restore manually if needed

Target definitions

targets/*.json

Recreated by bootstrap based on detected services


Part 2: Exporters (Target Nodes)

Run these steps on each monitored server (ACS, MySQL, etc.):

Step 1: Stop all exporter containers

cd /opt/grafana/exporters
for dir in */; do
  [ -f "$dir/docker-compose.yml" ] && docker compose -f "$dir/docker-compose.yml" down
done

Step 2: Remove exporters directory

rm -rf /opt/grafana/exporters

Step 3: Reinstall exporters

# Install latest stable version
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- exporters --branch 1.6.9

# Or install specific version (downgrade)
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- exporters --branch 1.6.9

What gets removed on exporter nodes:

Component Location Effect

Base exporters (node, process, cAdvisor)

exporters/base/

System/container metrics collection stops

JMX exporters

exporters/jmx-wildfly/

Java/WildFly metrics collection stops

MySQL exporter

exporters/mysql/

Database metrics collection stops

Note: Exporter clean install does not affect historical data - metrics are stored on the Prometheus server, not on exporter nodes.

⚠️ WARNING: Clean downgrade erases all monitoring history. Use only when:

  • Standard downgrade causes errors

  • Grafana/Prometheus fails to start after version change

  • You need a guaranteed working state


Capturing a Bootstrap Run

bootstrap.sh does not write log files. Session logging existed briefly and was removed (fef34da) — no production installer writes logs to disk, so there is no .bootstrap-logs/ directory to collect.

To capture a run for support, pipe it through tee. Only stdout is redirected, so stdin stays a terminal and every interactive prompt still works:

bash /opt/grafana/misc/bootstrap.sh <args> 2>&1 \
  | tee /tmp/bootstrap-$(date +%Y%m%d-%H%M%S).log

For a snapshot of the host rather than of one run — Docker, containers, ports, firewall and Prometheus targets — use the built-in diagnostic:

sudo bash /opt/grafana/misc/diagnose.sh

Verifying the Installation

Everything below runs entirely offline — no internet access and no tools beyond what a standard Linux host and this repository already provide.

1. Containers are up:

docker ps

Nothing should be in a Restarting state. Give nginx_proxy a specific look: a crash-looping nginx makes both Grafana and Prometheus unreachable from outside, while every exporter container next to it still shows Up and looks perfectly healthy — the outage is easy to miss if you only skim the list.

2. The built-in diagnostic:

sudo bash /opt/grafana/misc/diagnose.sh

Checks Docker, containers, ports, firewall, and Prometheus targets in one pass.

3. Every Prometheus target is up:

curl -s http://localhost:9090/api/v1/targets | python3 -c '
import json, sys
data = json.load(sys.stdin)["data"]["activeTargets"]
down = [t for t in data if t["health"] != "up"]
print("{} targets, {} not up".format(len(data), len(down)))
for t in down:
    lb = t["labels"]
    print("  {}: {} {}".format(t["health"], lb.get("job"), lb.get("instance")))'

4. Grafana answers:

DOMAIN=$(grep -m1 '^GRAFANA_DOMAIN=' /opt/grafana/prometheus-grafana-stack/.env | cut -d= -f2)
curl -sk -o /dev/null -w '%{http_code}\n' -H "Host: $DOMAIN" https://127.0.0.1/login

Expect 200. Two details make this check fail for the wrong reason if you shorten it:

  • The Host header is required. nginx serves the stack from a server_name ${GRAFANA_DOMAIN} block and keeps a default_server that answers everything else with return 444 — a silent close. So curl https://localhost/login gets an empty reply and %{http_code} prints 000, which looks exactly like a dead Grafana while the stack is perfectly healthy.

  • Probe through nginx on 443, not Grafana’s own port (see Accessing Web Interfaces). Grafana binds to 127.0.0.1:3100 only, so the internal port answers on the host itself but never from another machine.

To separate “nginx is broken” from “Grafana is broken”, query Grafana directly on the host — 200 here with a failure above means the proxy is the problem:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3100/login

5. The dashboards actually have data:

cd /opt/grafana && bash misc/testing/check-dashboard-panels.sh --quiet

A target being UP is not the same as a dashboard working: a panel can query a metric series that simply does not exist on this host, and it then shows “No data” with nothing anywhere reporting a problem. This script runs every panel’s own query against Prometheus and reports which ones come back empty. The exit code is the number of dashboards with at least one unexpected empty panel, and credentials are read from .env automatically — no flags needed on a standard deployment.

Some empty panels are expected and not a sign of a broken install:

  • windows-exporter-iis-node — empty when there are no Windows hosts in this deployment

  • oracle — empty on a MySQL deployment (and the mysql dashboards empty on an Oracle one)

  • api-metricsapi_method_requests_total only appears after real API traffic has occurred

  • business-metrics — needs devices in the FTACS database

  • a handful of panels in nodes-monitoring / system-dashboard — Pressure Stall Information needs a recent kernel, power-supply panels need hardware sensors that may not exist on this host/VM

On the reference monitoring host, a run reports 13 empty panels out of 475 — every one explained by something that host doesn’t have, not by a defect. That count is not a target to match; it moves with what each host actually runs.


Troubleshooting

Quick Diagnostics: Run sudo bash /opt/grafana/misc/diagnose.sh to automatically check Docker, containers, ports, firewall, and Prometheus targets.

Common Issues and Solutions

Prometheus cannot connect to exporter

Symptoms: Target is DOWN in Prometheus UI, curl hangs

Step 1: Is exporter listening?

ss -ltpn | grep <port>

Step 2: Is container running?

docker ps
docker logs -f <CONTAINER_ID>

Step 3: Is exporter accessible from Prometheus?

docker exec $(docker ps | grep prom/prometheus | awk '{print $1}') \
  wget http://<EXPORTER_IP>:<PORT>/metrics -O /tmp/test

If connection hangs — check firewall allows access from Prometheus host.


Cannot Login to Grafana

Symptom: Grafana login page loads but admin password is rejected (Invalid username or password)

Cause: Grafana stores the admin password in its SQLite database on first startup. Changing GRAFANA_ADMIN_PASSWORD in .env and restarting the container does not update the existing password — it only applies when creating a new admin user.

Solution: Reset password via CLI:

# On the Grafana server:
bash /opt/grafana/misc/bootstrap.sh --reset-password "new_password"

# Or directly:
docker exec grafana grafana cli admin reset-admin-password "new_password"

Spaces in .env Files

Wrong: MYSQL_USER = exporter (spaces around =)

Correct: MYSQL_USER=exporter (no spaces)


Forgetting to Create .env File

Symptom: Containers start but use default/empty values

Solution: Always run cp .env.example .env before docker compose up


Firewall Rules Not Persistent

Problem: Rules disappear after reboot

Solution: Our scripts auto-save rules. Manual save:

# Ubuntu/Debian
netfilter-persistent save

# CentOS/Rocky
service iptables save

Wrong Prometheus IP in Firewall

Symptom: Prometheus shows targets as “DOWN”

Solution: Verify Prometheus IP and update firewall rules.


Firewall faults that present as something else

Three of them, each with a symptom that names the wrong thing: containers that cannot reach the network (a rebuilt FORWARD chain), SSH reporting No route to host on a host that is up (a connection limit on port 22), and a host exporter that cannot reach a healthy container (an OUTPUT drop covering the Docker bridge).

They are written up once, with the checks that tell them apart and the fix for each, in the Docker + UFW guide. They used to be repeated here as well; two copies of the same three faults is two copies to keep in step, and this is the one that was going to fall behind.

sudo bash /opt/grafana/misc/diagnose.sh reports all three under “Traps that present as something else”.


Docker asks for authorization: unauthorized to access repository

Error response from daemon: unauthorized: unauthorized to access repository:
monitoring/alertmanager-config-ui, action: pull

Two of the stack’s images — snmp-catcher and alertmanager-config-ui — are ours and live in a private project on the hub. Every other image is public and needs nothing.

The same missing credential can also arrive worded as a missing tag. On a host with no login, docker manifest inspect reports

no such manifest: hub.friendly-tech.com/monitoring/snmp-catcher:1.6.9

for an image that exists — measured, and it sends people to check the version number instead of the login. docker pull on the same reference says unauthorized. misc/diagnose.sh reports either wording as a probable missing login.

On an online install this is expected the first time: log in once on that host and re-run. Bootstrap checks for it before starting the stack and stops with the same instruction, so you should see it before Docker’s own error.

docker login hub.friendly-tech.com -u ast     # read-only account, ask for the password

On an offline install this must never happen, and if it does it is telling you the images are not on the host — nothing there should be reaching a registry at all. In order of likelihood:

  • the bundle was not loaded, or was loaded on a different machine — docker images | grep monitoring/ should list both

  • it was loaded as a different user, so it went to another Docker context — check with the same user that runs the install

  • someone edited SNMP_CATCHER_VERSION or ALERTMANAGER_CONFIG_UI_VERSION in .env, so the tag being asked for is not the tag in the bundle. Compare docker images --format '{{.Repository}}:{{.Tag}}' | grep monitoring/ against those two variables

An air-gapped host cannot log in to anything, so a login prompt there is always a missing-image problem, never a credentials problem.

Conflicting Container Names

Problem: “The container name is already in use”

Solution:

docker compose down
docker compose up -d

MySQL exporter shows mysql_up 0

Solution:

systemctl status mysql
mysql -u exporter -p -e "SHOW GRANTS;"

Nginx 502 Bad Gateway

Solution:

docker logs nginx_proxy
docker exec nginx_proxy env | grep GRAFANA_SERVICE_TOKEN
docker compose restart nginx

cAdvisor shows no container metrics (containerd image store)

Symptom: cAdvisor returns only root container (id="/"), no individual Docker containers visible. System Dashboard Basic shows no FT UI Services. cAdvisor logs show:

Failed to create existing container: failed to identify the read-write layer ID
open /rootfs/var/lib/docker/image/overlayfs/layerdb/mounts/.../mount-id: no such file or directory

Cause: Storage Driver: overlayfs means Docker uses the containerd image store — the default for fresh installs of Docker Engine ≥ 29 (opt-in on 28, and on Debian 13 / RHEL 9 static-tarball installs). There is no /var/lib/docker/image//layerdb in that mode, and *cAdvisor before v0.54.0 cannot resolve a container’s read-write layer: it skips every container and reports only the root cgroup, so all container_* series for that host are lost.

Upstream fixed this in cAdvisor v0.54.0 (PR #3709, released 2025-12-02) — it reads the container rootfs from the containerd spec instead of the graphdriver metadata. This repo pins a newer build, so the fix is the cAdvisor version, not the Docker storage driver.

Two conditions, not one. Because the new code talks to containerd, cAdvisor’s docker factory needs the socket dockerd actually uses. With a wrong path the container starts, the read-write layer ID spam stops and everything looks fixed, but the log says Registration of the docker container factory failed: …​ dial unix /run/containerd/containerd.sock and every container_* series arrives without name/container_label_* — only raw cgroup ids (verified on grafrd: 13 named containers with a reachable socket, 0 without). cAdvisor’s default is /run/containerd/containerd.sock, correct for package installs; the offline static-tarball install (dockerd spawns its own containerd) keeps it under /run/docker/containerd/. Hence CONTAINERD_SOCK in exporters/base/.env, detected by detect_containerd_sock() (misc/lib/container-helpers.sh) on every quick-deploy run.

Check:

docker info | grep -E 'Storage Driver|driver-type'   # overlayfs + io.containerd.snapshotter.v1
docker inspect --format='{{.Config.Image}}' cadvisor  # must be >= v0.54.0
docker logs --since 10m cadvisor 2>&1 | grep -c 'read-write layer ID'
docker logs --since 10m cadvisor 2>&1 | grep 'docker container factory'
source /opt/grafana/misc/lib/container-helpers.sh && detect_containerd_sock

Solution — upgrade cAdvisor and pin the socket. No Docker restart, no downtime for other containers:

cd /opt/grafana/exporters/base
sed -i 's|^CADVISOR_VERSION=.*|CADVISOR_VERSION=v0.55.1|' .env   # or add the line if missing
source /opt/grafana/misc/lib/container-helpers.sh
SOCK=$(detect_containerd_sock)
grep -q '^CONTAINERD_SOCK=' .env \
  && sed -i "s|^CONTAINERD_SOCK=.*|CONTAINERD_SOCK=$SOCK|" .env \
  || echo "CONTAINERD_SOCK=$SOCK" >> .env
docker compose up -d cadvisor

Air-gapped host — load the image first. A ready archive is published on the hub, together with a step-by-step page for customers: https://hub.friendly-tech.com/docs/pdf/grafana-cadvisor-fix.html

curl -fLO https://hub.friendly-tech.com/docs/pdf/cadvisor-v0.55.1.tar.gz
sha256sum cadvisor-v0.55.1.tar.gz
# expect 3439641369dea7121fc3ed1fb2722e979421b0bab3c574d8115fd12d977aa204
# transfer, then on the target host:
docker load -i cadvisor-v0.55.1.tar.gz

Or build it yourself on any machine with internet access:

docker pull gcr.io/cadvisor/cadvisor:v0.55.1
docker save gcr.io/cadvisor/cadvisor:v0.55.1 -o cadvisor-v0.55.1.tar
tar tf cadvisor-v0.55.1.tar | grep -m1 manifest.json   # classic format, loads anywhere

Verify:

docker logs --since 3m cadvisor 2>&1 | grep -c 'read-write layer ID'       # expect 0
curl -s 127.0.0.1:9183/metrics | grep -o 'name="[^"]*"' | sort -u | wc -l  # expect your container count

Last resort — switch Docker back to the classic store. Only for hosts that must keep an old cAdvisor build. Graph drivers are deprecated in Docker 29, this restarts Docker, and images already written to the containerd store disappear from docker images afterwards (they are not deleted — reverting daemon.json brings them back):

# add both keys to /etc/docker/daemon.json, keeping any existing settings:
#   {"features": {"containerd-snapshotter": false}, "storage-driver": "overlay2"}
systemctl restart docker
docker info | grep 'Storage Driver'   # should show overlay2
# air-gapped: re-load images before starting anything
docker load -i docker-images-bundle.tar.gz
cd /opt/grafana/exporters/base && docker compose up -d

On a fresh Docker 29 install this may not take effect at all — the daemon keeps the containerd store while its state exists, and a real switch means wiping /var/lib/docker. Upgrade cAdvisor instead.

Note: Bootstrap detects the containerd image store on every run and compares it with the deployed cAdvisor version: with v0.54.0+ it just logs that container metrics are supported; with an older build it prints an ACTION REQUIRED block in the final summary. diagnose.sh performs the same check. The --fix-storage-driver flag performs the last-resort switch above (works in non-interactive runs too), and is no longer offered interactively.


Grafana alerts not sending emails

Solution:

docker logs grafana | grep -i smtp
# Test in UI: Alerting > Contact points > email > Test

Alertmanager Config UI: “Failed to update .env file”

The UI runs as uid 1000 while bootstrap.sh runs as root, so the files it edits must be group-owned by the host docker group — the same group that lets it restart containers. Re-running bootstrap.sh stack sets this up (DOCKER_GID in .env, plus 0660 on .env and 0664 on the templates and prometheus_rules.yml, which other containers still have to read).

bootstrap.sh stack records two host-specific values in .env, and the UI needs both:

  • DOCKER_GID — the group that owns the socket. compose passes it via group_add; the compose fallback of 999 matches almost no host.

  • DOCKER_SOCK — where that socket is. Rootful Docker uses /var/run/docker.sock, Podman keeps its own elsewhere. compose mounts this path at the fixed in-container location, so the app never has to know which runtime it is talking to.

A stale or missing value here fails quietly: the container is Up and healthy, and only a save shows that nothing was applied. misc/diagnose.sh reports the mismatch under “Traps that present as something else”.

Check what the container actually sees:

cd /opt/grafana/prometheus-grafana-stack
docker compose exec alertmanager-config-ui sh -c 'id; ls -l /app/.env'
# root:root 0644 -> the grant is missing; re-run bootstrap, or:
chgrp "$(getent group docker | cut -d: -f3)" .env && chmod 0660 .env

A related symptom is “Configuration saved” with nothing changing: the write succeeded but the restart that applies it did not, because neither Alertmanager nor snmp-notifier re-reads .env while running. The UI then names the service that is still on the old settings. Applying it by hand:

docker compose up -d alertmanager snmp-notifier

If the UI keeps behaving like an older build after an upgrade, check which image tag is running. Since 1.6.9 it is pulled from our registry rather than built on the host, so an upgrade means pulling a new tag — docker compose pull alertmanager-config-ui && docker compose up -d alertmanager-config-ui (see prometheus-grafana-stack/alertmanager-config-ui/README.md).


Operations

Updating Monitoring Stack

curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9

Select “Keep current configuration” to preserve your .env settings.

For exporters:

cd /opt/grafana
sudo bash /opt/grafana/misc/quick-deploy.sh PROMETHEUS_IP

Just refresh the code, deploy nothing--update pulls the requested branch into /opt/grafana and stops. It needs no PROMETHEUS_IP, installs nothing, and restarts no container, so it is safe on a server whose stack is running:

curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --update --branch 1.6.9

It prints the commit it moved from and to. Use it to pick up a fix and apply it deliberately afterwards (bootstrap.sh stack, quick-deploy.sh, or a single docker compose up -d).

Why not plain git pull? The repository is private and servers are installed with an HTTPS remote and no stored credentials — git pull there fails with could not read Username for 'https://github.com'. The hub wrapper carries the token, hands it to bootstrap, and the token is stripped from the remote again right after the pull. An extracted offline copy has no history at all: --update says so instead of reporting success, and such a host is refreshed by delivering a new archive.

Updating Configuration

vi .env
docker compose up -d
docker compose restart nginx  # if nginx config changed

Updating Docker Images

vi .env  # Set the version, e.g. PROMETHEUS_VERSION=v3.13.1
docker compose pull prometheus
docker compose up -d prometheus

The value in .env is what actually runs. The ${VAR:-default} in docker-compose.yml is a fallback that must not be relied on — if .env has no entry for an image, the compose default silently decides the version, which is how a host ended up running Prometheus v3.5.0 while every document described v3.13.1. Compare against .env.example after any upgrade.

Rollback Deployment

The deployment script creates a manifest tracking all changes. To rollback:

# Rollback to previous state (latest manifest)
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --rollback

# Or directly:
sudo /opt/grafana/misc/quick-deploy.sh --rollback

# List available manifests
sudo /opt/grafana/misc/quick-deploy.sh --list-manifests

# Rollback specific deployment
sudo /opt/grafana/misc/quick-deploy.sh --rollback /opt/grafana/.deploy-manifests/manifest-20260201-143022

What gets rolled back:

  • Stops deployed containers

  • Removes added firewall rules

  • Restores original configuration files (WildFly, Hazelcast)

Note: Services like WildFly/ACS may need manual restart after rollback.

Offline/Air-gapped Installation

Docker must already be installed on the air-gapped server (install-docker.sh needs internet).

Prepare (machine with internet) — use the 1.6.9 branch ZIP (not main), and build on a host with the classic Docker image store (Docker Desktop / Engine ≥ 28 use the containerd store → docker save writes OCI → old Docker on the target fails with unrecognized image format):

bash misc/download-docker-images.sh   # linux/amd64 by default; includes built stack images + Grafana plugins
tar tf docker-images-bundle.tar* | grep -m1 manifest.json && echo CLASSIC_OK   # NOT oci-layout
scp grafana-1.6.9.tar.gz docker-images-bundle.tar.gz user@server:/tmp/
scp -r docker-images-bundle-grafana-plugins user@server:/tmp/

Install exporters (air-gapped server):

sudo docker load -i /tmp/docker-images-bundle.tar.gz
cd /opt && sudo tar xzf /tmp/grafana-1.6.9.tar.gz && sudo mv grafana-1.6.9 grafana
sudo bash /opt/grafana/misc/quick-deploy.sh PROMETHEUS_IP

Install monitoring stack (air-gapped server): load the bundle and the repository as above, then run bootstrap. Run it plain for the interactive configurator, or add -y to skip every prompt:

sudo docker load -i /tmp/docker-images-bundle.tar.gz
sudo bash /opt/grafana/misc/bootstrap.sh stack       # interactive: prompts for domain, passwords, SMTP
sudo bash /opt/grafana/misc/bootstrap.sh stack -y    # non-interactive: no questions; auto IP-as-domain + random passwords

With Docker and the repo already present, bootstrap runs fully offline — self-signed cert, htpasswd, alertmanager placeholder, containers from pre-loaded images, base exporters, targets — and auto-stages the bundled Grafana plugins if docker-images-bundle-grafana-plugins/ is next to /opt/grafana or in /tmp. No manual section-8 steps needed (see the Quick Start offline section for details and the Oracle datasource plugin).

Every image is pulled; nothing is built on the target host. snmp-catcher and alertmanager-config-ui are built by CI and published to the private monitoring project, so an online install needs one docker login first (see Container images). Bootstrap checks both are present or pullable before starting the stack and stops with that instruction if not — an image already on the host, loaded from an offline bundle, is used as-is and never re-pulled. Versions are pinned in .env.

Security Best Practices

  • Never commit .env files to git

  • Use strong passwords (16+ characters)

  • Restrict permissions: chmod 600 .env

  • Rotate passwords every 90 days

  • Use dedicated users with minimal grants

Deployment Checklist

Before Starting:

  • Docker installed, repository cloned to /opt/grafana

  • Prometheus IP address known

  • Credentials gathered (MySQL/Oracle/SMTP)

For Each Server:

  • .env configured, containers deployed

  • Metrics endpoints working (curl localhost:PORT/metrics)

  • Firewall rules saved

On Prometheus/Grafana:

  • Targets show “UP” in Prometheus UI

  • Dashboards loading, alerts configured

  • Nginx proxy working (/grafana-ro/)


For Developers

Documentation

This repository automatically maintains Antora-formatted documentation alongside the Markdown README.

Automatic Conversion

When you commit changes to README.md, a pre-commit hook automatically converts it to AsciiDoc format for Antora documentation. The converted file is placed in docs/modules/ROOT/pages/index.adoc and staged for the commit.

Setting Up Git Hooks

After cloning this repository, install the Git hooks:

./misc/install-hooks.sh

This installs a pre-commit hook that:

  • Detects when README.md is staged for commit

  • Converts it to AsciiDoc using pandoc

  • Stages the converted index.adoc file automatically

Requirements:

  • pandoc must be installed:

# macOS
brew install pandoc

# Ubuntu/Debian
sudo apt-get install pandoc

# CentOS/RHEL
sudo yum install pandoc
Manual Conversion

To manually convert the documentation:

./misc/md2adoc.sh
Antora Structure
docs/
├── antora.yml                    # Antora component descriptor
└── modules/
    └── ROOT/
        ├── nav.adoc              # Navigation menu
        └── pages/
            └── index.adoc        # Main documentation (auto-generated)

Container images

Every image this stack runs exists in our own registry, scanned, at the version we ship. Not for convenience: an install that pulls from six third-party registries is only as available as the least reliable of them, and versioned tags do disappear — Bitnami deleted every one of jmx-exporter’s, which is why that exporter is pinned by digest.

  • hub.friendly-tech.com/mirror/… — the third-party images (Prometheus, Grafana, Alertmanager, the exporters, nginx, mailpit). The Harbor project is public with automatic scanning, so pulling needs no credential.

  • hub.friendly-tech.com/monitoring/… — the two images we build ourselves, snmp-catcher and alertmanager-config-ui, tagged with the version branch that built them. This project is private, so an online install needs one docker login on the target host before deploying the stack. An air-gapped install needs none: the images come in the offline bundle like every other.

    Until 1.6.9 these two were built on the customer’s host at install time — the only images in the stack that were. That needed egress to PyPI and the Alpine mirrors exactly where it is least likely to exist, and a failed build left the install running without the service. Nothing is built on a target host any more.

Read-only account for the private project:

registry: hub.friendly-tech.com
username: ast
docker login hub.friendly-tech.com -u ast

It is guest on both projects — it can pull and nothing else; a push is rejected. Ask for the password rather than reusing a personal account.

Harbor locks an account after repeated failed logins, and then refuses the correct password from every host — docker login answers Get "https://…/v2/": unauthorized, which reads like a client or network problem and is not. If that happens, an administrator clears it by resetting the password.

How the mirror stays in step with what we install. The list is never maintained by hand. misc/list-stack-images.sh computes it from the same .env.example and compose files the runtime reads, and the Mirror stack images workflow (mirror-images.yml) runs on any change to those files — so bumping a version in .env.example is what syncs it. It copies with skopeo --all, keeping multi-arch images multi-arch, and fails the run rather than leaving the mirror partial. A weekly run catches anything lost to a registry cleanup.

bash misc/list-stack-images.sh            # what we ship, at its pinned version
bash misc/list-stack-images.sh --mirror   # the same, at our registry

What redirects a deployment to the mirror is a line in .env, not the compose file. The compose files keep naming the UPSTREAM image as their default — that is what tells the mirror workflow what to copy — and .env.example carries NAME_IMAGE=hub.friendly-tech.com/mirror/… for each of them. Since .env.example is only copied when there is no .env, a host installed before the mirror existed would keep asking for upstream names that the offline bundle does not contain — an air-gapped upgrade reaching for Docker Hub, on exactly the hosts that cannot reach it. So bootstrap and quick-deploy re-apply the pins on every run, the same way they refresh DOCKER_GID and CONTAINERD_SOCK. A pin already in .env is never touched, including a commented-out one — that is how you keep a single image on upstream.

The two images we build are published by their own workflows, which fire when their sources change (prometheus-grafana-stack/snmp-catcher/ and …/alertmanager-config-ui/).


Continuous Integration

Six workflows live in .github/workflows/:

  • Sandbox tests (sandbox-tests.yml) — runs the installer test suite in a container on every push to a 1.* branch and on every pull request.

  • Generate PDF Documentation (docs-pdf.yml) — rebuilds the PDF handouts.

  • Trigger Docs Portal Rebuild (trigger-docs-portal.yml) — notifies the portal repository when README.md, docs/* or VERSION changes. Note the portal builds only from 1. branches and tags, so a docs fix on main changes nothing.

  • Build SNMP Catcher / Build Alertmanager Config UI — publish those two images to Harbor, tagged by branch. latest is pushed only from main.

  • Fixture Drift (fixture-drift.yml) — weekly comparison of the sandbox fixtures against the reference host, so the mocks cannot go on replaying a world that has moved. Two jobs: record runs on a self-hosted runner on the reference host, because its ufw admits inbound ssh from an allowlist that hosted runners are not on, and a runner long-polls outbound instead; analyse runs hosted, because the secret scan and the sandbox suite need Docker and that runner’s account deliberately has none. It opens one rolling issue rather than committing, because a fixture change alters what every sandbox case believes about reality. Setup in misc/testing/sandbox/README.md.

Two things about triggers are worth knowing before adding a workflow here.

schedule runs on the default branch only. GitHub takes the workflow from the latest commit on main regardless of where it was developed, so a weekly job that lives only on a version branch never fires. That is why fixture-drift.yml is on main as well as on the development line.

workflow_dispatch is not limited to the default branch — it dispatches against any ref, and the version that runs is the one on that ref. Measured 05.08.2026: gh workflow run fixture-drift.yml --ref 1.6.9 ran seven hours before the file existed on main at all.

What does trip people up is registration, which is a different thing: GitHub does not know a workflow exists until some event has caused it to read the file. A workflow whose only triggers are schedule and workflow_dispatch gives it no such event on a version branch, so the API reports it as non-existent and there is nothing to dispatch. Push the file with any trigger that fires once — a temporary push: is enough — and dispatch works from then on, on any branch.


Development & Releases

The project uses version branches for development and releases. This describes what misc/release.sh does today.

A trunk-based model was agreed instead (DEV-1872, 23.07.2026) and is written up in docs/RELEASE-WORKFLOW.md — but the tooling has not been changed to match, so that document is the target and this section is the practice. Do not follow it expecting release.sh to behave that way.

Workflow:

1. Work on version branch (e.g., 1.6.9)
2. When ready: ./misc/release.sh [patch|minor|major]
   → Clears the prerelease marker in docs/antora.yml
   → Merges to main, tags, creates the GitHub Release with a changelog
   → DELETES the version branch, locally and on the remote
   → Creates the next version branch (e.g., 1.6.10) with the marker back
3. Continue development on new branch

Two steps in there surprise people, so they are spelled out rather than left in the script:

  • The version branch is deleted, both locally and on the remote. It has been merged and tagged by then, so nothing is lost — but anything still unpushed on it is, and a colleague tracking that branch will find it gone.

  • The prerelease: marker is stripped before the merge. Every new development branch carries prerelease: -dev in docs/antora.yml, and nothing used to remove it, so a released line kept publishing itself to the docs portal as X.Y.Z-dev, sorted below an older clean version and never becoming latest. If a release ever shows up on the portal with a -dev suffix, this is the step that did not run.

Version branch naming:

  • Branches: 1.6.9 (without v prefix)

  • Tags: 1.6.9 (without v prefix)

  • Releases: 1.6.9 (without v prefix)

Using specific versions:

# List available versions
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- --version-list

# Install specific version
curl -fsSL https://hub.friendly-tech.com/bootstrap/install.sh | sudo bash -s -- stack --branch 1.6.9


branch 1.6.9 · commit a862b45d92590285f229f80a6b5d2efabcb3b1b0 · page generated August 08, 2026