====== NIG Workshop Stack: Multi-Instance Node-RED, InfluxDB & Grafana ====== This page documents how we built our own small "cloud" for hosting multiple isolated instances of a **Node-RED + InfluxDB + Grafana** stack ("NIG") behind a single NGINX reverse proxy. The setup is what we use to give every workshop participant their own private playground on one shared server. **This is a workshop / development setup. Do //not// use this in production without a proper security review.** \\ Passwords are reused across services, the InfluxDB image is the unmaintained 1.x line, and there is no rate limiting or per-user network isolation. It is intentionally simple so it stays understandable. ===== What this page is (and isn't) ===== The goal here is to **explain the architecture** so you understand //what// is running, //why// it is running, and //how the pieces talk to each other//. The intended reader is a workshop participant — possibly a future lab staff member or someone from industry — who wants to understand the system well enough to either operate it, adapt it, or build something similar later. **You are not expected to copy this 1:1.** \\ The multi-instance, NGINX-fronted, automated-deployment version is overkill for a single person learning Node-RED. If you just want a local NIG stack on your own laptop, jump to [[#running_a_local_nig_instance_from_the_repository|Running a local NIG instance]] at the end of this page. The middle sections are reference material for the people maintaining the workshop server. All scripts, configs and the Node-RED management flow shown below live in our public repository: * **Repository:** [[https://github.com/EOLab-HSRW/nig-workshop-stack|github.com/EOLab-HSRW/nig-workshop-stack]] ===== Architecture overview ===== The big idea is that **every workshop user gets their own isolated stack** — three Docker containers (Node-RED, InfluxDB, Grafana) — all sitting behind a //single// public NGINX entrypoint. Users do not need to know any port numbers; they reach their tools through clean URL paths like ''/student1/node-red/'' and ''/student1/grafana/''. ┌─────────────────────────────────┐ │ Public DNS: nig.eolab.de │ └─────────────────────────────────┘ │ HTTPS (443) ▼ ┌─────────────────────────────────────┐ │ NGINX reverse proxy (host) │ │ Wildcard Let's Encrypt cert │ │ includes /etc/nginx/nigs/*.conf │ └─────────────────────────────────────┘ │ │ │ /student1/... │ /student2/... /studentN/... ▼ ▼ ▼ ┌───────────────────┐ ┌──────────────┐ ┌────────────┐ │ student1 stack │ │ student2 ... │ │ studentN.. │ │ ┌────────────┐ │ │ │ │ │ │ │ Node-RED │ │ │ │ │ │ │ │ Grafana │ │ │ │ │ │ │ │ InfluxDB │ │ │ │ │ │ │ └────────────┘ │ │ │ │ │ └───────────────────┘ └──────────────┘ └────────────┘ ▲ │ runs/stops/regenerates configs │ ┌────────────────────┐ │ Management │ │ Node-RED (host) │ ← reads inventory.csv, │ │ writes per-user nginx files, └────────────────────┘ runs deploy.sh There are two distinct "layers" of Node-RED in this setup, and it's important not to confuse them: * **The student stacks** — one per user, containerised, isolated. This is what workshop participants actually use. * **The management Node-RED** — a //single// Node-RED instance running on the host (not in Docker) that we use as a simple ops UI. It reads the user inventory, regenerates NGINX configs, reloads NGINX, and triggers the deploy script. We use it as a convenient front-end for the shell commands. It is also a nice (slightly meta) example of "what Node-RED is good for". ===== Server setup ===== The following steps are roughly the order in which we set up the host. They assume a freshly provisioned Ubuntu server with SSH access and a domain you control (in our case ''nig.eolab.de''). ==== Base packages ==== sudo apt update sudo apt install -y nano snapd fuse nginx sudo snap install core sudo snap refresh core We install **Snap** because we use it to install Certbot — the Snap version is the one Certbot maintainers currently recommend, and it gives us up-to-date plugins. ==== TLS certificates via Certbot (with IONOS DNS challenge) ==== We want a **wildcard certificate** for ''*.example.com'' so that every workshop URL is reachable over HTTPS without needing a separate certificate per student. Wildcards can only be issued via the **DNS-01 challenge** (HTTP-01 cannot prove control over a wildcard), which means Certbot has to be able to create temporary TXT records on our DNS provider. **Provider-specific section.** \\ We use **IONOS** as our DNS provider, so we install the ''certbot-dns-ionos'' plugin. If you use a different provider (Cloudflare, Route53, ...), you'll install a different plugin but the rest of the workflow is the same. Install Certbot and the IONOS plugin: sudo snap install --classic certbot sudo ln -s /snap/bin/certbot /usr/bin/certbot sudo snap set certbot trust-plugin-with-root=ok # IONOS DNS plugin sudo snap install certbot-dns-ionos sudo snap connect certbot:plugin certbot-dns-ionos Create an IONOS API user (in the IONOS control panel, under //System → Remote Users//, with rights for //Client Functions//, //DNS zone functions// and //DNS txt functions//) and store the credentials in an INI file readable only by root: dns_ionos_prefix = dns_ionos_secret = dns_ionos_endpoint = https://api.hosting.ionos.com sudo chmod 600 /root/ionos.ini Make sure the following DNS records exist for your domain: ^ Record ^ Name ^ Value ^ | A | ''*'' | server public IP | | A | ''@'' | server public IP | | CNAME | ''www'' | ''@'' | Now request the wildcard certificate: sudo certbot certonly \ --authenticator dns-ionos \ --dns-ionos-credentials /root/ionos.ini \ -d '*.example.com' -d 'example.com' For the **manual** variant (no plugin), the equivalent invocation is: sudo certbot --server https://acme-v02.api.letsencrypt.org/directory \ -d '*.example.com' --manual --preferred-challenges dns-01 certonly You will be asked to create a ''_acme-challenge'' TXT record by hand. Use the manual flow only if a plugin is not available — automatic renewal won't work for the manual challenge. ==== NGINX site configuration ==== We replace the default NGINX site with our own. Two files matter: - **''/etc/nginx/nginx.conf''** — the global config. Lightly customised: the only line that matters for this setup is that ''sites-enabled'' is //included//, which lets us drop in our virtual hosts as separate files. - **''/etc/nginx/sites-available/nig.eolab.de''** — our virtual host. Symlinked from ''sites-enabled''. This is the file that terminates TLS and proxies into the student stacks. Remove the default site and copy our template in its place: sudo rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default sudo nano /etc/nginx/sites-available/nig.eolab.de sudo ln -s /etc/nginx/sites-available/nig.eolab.de /etc/nginx/sites-enabled/nig.eolab.de Generate Diffie-Hellman parameters once (used for older clients): sudo openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048 The key trick is that our site config does **not** list every student location explicitly. Instead, it ''include''s a directory we manage with our Node-RED management flow: # inside the server { ... } block of /etc/nginx/sites-available/nig.eolab.de include /etc/nginx/nigs/*.conf; Every time the inventory changes, the management flow writes one ''.conf'' file into ''/etc/nginx/nigs/'' and reloads NGINX. This is what lets us add or remove students without touching the main site file. After every change, validate and reload: sudo nginx -t sudo systemctl reload nginx ==== Docker ==== Install Docker and Docker Compose using the official Docker repository (instructions: ). Then enable the services so they come back after a reboot: sudo systemctl enable docker.service sudo systemctl enable containerd.service ==== Cloning the stack repository ==== cd /root git clone https://github.com/EOLab-HSRW/nig-workshop-stack cd nig-workshop-stack This gives you ''deploy.sh'', ''compose.yml'', ''gen_instances_files.py'', ''globals.env'' and ''inventory.csv'' — everything the next sections will use. ===== How a stack is built: the repository ===== The repo is small on purpose. Here is what each file does and why it exists. ==== inventory.csv — the source of truth ==== This is the //only// file you edit during a workshop. One row per user, with their credentials and the host ports their containers will bind to. USER_NAME,USER_PASSWORD,NODE_RED_PORT,NODE_RED_EXTRA_PORT,GRAFANA_PORT student1,dump_password,17601,1881,17701 student2,dump_password,17602,1882,17702 * ''NODE_RED_PORT'' — host port mapped to Node-RED's editor (container port 1880). * ''NODE_RED_EXTRA_PORT'' — host port mapped to Node-RED's "extra" port (container 1881). We expose this so students can spin up their own HTTP listeners, MQTT brokers, etc. on a port that doesn't collide with anyone else's. * ''GRAFANA_PORT'' — host port mapped to Grafana (container 3000). * **Every port must be unique** across all rows. The generator script enforces this. ==== globals.env — shared defaults ==== Values that apply to every instance: which images to use, which timezone, the public hostname for absolute URL generation. ROOT_URL=nig.eolab.de NODE_RED_IMAGE=nodered/node-red:4.1.10-22 INFLUXDB_IMAGE=influxdb:1.12.4 GRAFANA_IMAGE=grafana/grafana:13.0.1 TZ=Europe/Berlin INFLUXDB_DB=db INFLUXDB_ADMIN_USER=admin BCRYPT_ROUNDS=8 We pin every image to an exact tag. Workshops are easier to support when the version doesn't shift under you. ==== gen_instances_files.py — turning rows into env files ==== For each row in ''inventory.csv'', this Python script writes one file ''instances/.env'' that Docker Compose can later consume with ''--env-file''. The interesting bits: * **Port collision detection.** Two rows that share a port are rejected up front instead of producing a Docker error later. * **Stable credential secret.** Node-RED encrypts saved credentials (e.g. MQTT passwords inside flows) with ''NODE_RED_CREDENTIAL_SECRET''. If this changes, all existing encrypted credentials become unreadable. The generator therefore //preserves// an existing secret across regenerations, and only creates a fresh one (via ''secrets.token_urlsafe(32)'') the first time around. * **Sensible defaults for derived fields.** The InfluxDB user/password default to the workshop user's own credentials, the Compose project name is derived from ''USER_NAME'' (so ''docker ps'' shows readable container names), and so on. * **Quoting safe for bcrypt hashes.** Bcrypt hashes start with sequences like ''$2b$08$...''. Docker Compose interprets ''$'' as variable interpolation. The generator wraps such values in single quotes, which Compose treats as literal. ==== deploy.sh — the orchestrator ==== This is the script you actually invoke. It composes three steps: - Run ''gen_instances_files.py'' to (re)generate ''instances/*.env''. - For each instance file, **hash the user's Node-RED password with bcrypt** by running a one-shot container against the pinned Node-RED image. This guarantees the hash is generated by the //same// bcrypt build that Node-RED itself will use to verify it. - For each instance file, call ''docker compose -p --env-file instances/.env -f compose.yml up -d''. Supported commands: ./deploy.sh up # generate, hash, start all instances ./deploy.sh up student1 # same, but only for student1 ./deploy.sh down # stop and remove (including volumes) all instances ./deploy.sh down student1 # stop and remove a single instance ./deploy.sh generate # only regenerate the .env files ./deploy.sh hash # only regenerate the bcrypt password hashes ./deploy.sh config # render the merged Compose config for inspection **Always use ''./deploy.sh up'', not ''docker compose up'' directly.** \\ A raw ''docker compose'' call won't generate the bcrypt hash, and Node-RED will refuse to start because ''NODE_RED_PASSWORD_HASH'' is empty. By default ''down'' removes the named Docker volumes (''-v''). Pass ''KEEP_VOLUMES=1 ./deploy.sh down student1'' if you want to preserve a user's flows and InfluxDB data across a restart. ==== compose.yml — the per-user stack ==== A textbook three-service Compose file. The only things worth highlighting: * **Strict required variables.** Most variables use the ''${VAR:?error message}'' form, which fails fast if Compose was invoked without the corresponding env file. This is what prevents "I forgot to run deploy.sh" from silently producing a broken stack. * **Per-user URL prefix.** ''NODE_RED_HTTP_ADMIN_ROOT'' and ''NODE_RED_HTTP_NODE_ROOT'' are set to ''//node-red/'' and ''//node-red/api'' respectively. This is how Node-RED knows to render all its asset URLs underneath the per-user path — required for the reverse proxy to work without rewriting HTML. * **Grafana sub-path.** ''GF_SERVER_ROOT_URL'' and ''GF_SERVER_SERVE_FROM_SUB_PATH=true'' do the same job for Grafana. * **Mounted settings.** ''./node-red/settings.js'' is bind-mounted read-only into ''/data/settings.js'' so we can override Node-RED's admin auth and root paths without baking a custom image. * **No published port for InfluxDB.** InfluxDB is only reachable via the internal Compose network as ''influxdb:8086''. Students cannot point Grafana at someone else's database — there is no host port to point at. ==== node-red/settings.js — Node-RED runtime config ==== A trimmed Node-RED ''settings.js''. The important parts: * Refuses to start if ''NODE_RED_PASSWORD_HASH'' or ''NODE_RED_CREDENTIAL_SECRET'' are missing, instead of starting with insecure defaults. * Configures ''adminAuth'' with a single user whose password is the bcrypt hash injected by ''deploy.sh''. * Sets ''httpAdminRoot'' and ''httpNodeRoot'' from environment variables so the same image works for any user without rebuilding. * Disables the projects feature — we don't want students accidentally bumping into git workflows during a 90-minute workshop. ==== grafana/provisioning/datasources/influxdb.yml — auto-wiring Grafana ==== Grafana supports "provisioning": YAML files dropped into ''/etc/grafana/provisioning'' are read at startup and used to declaratively create data sources, dashboards, etc. We use it to pre-create the InfluxDB data source so students don't have to do it by hand: apiVersion: 1 datasources: - name: InfluxDB type: influxdb access: proxy url: http://influxdb:8086 database: ${INFLUXDB_DB} user: ${INFLUXDB_USER} isDefault: true editable: true jsonData: httpMode: GET secureJsonData: password: ${INFLUXDB_USER_PASSWORD} The variables ''${INFLUXDB_DB}'', ''${INFLUXDB_USER}'', ''${INFLUXDB_USER_PASSWORD}'' are resolved by Grafana from its //container// environment at startup — that's why the Compose file sets them on the Grafana service even though Grafana itself doesn't need to know the DB credentials at runtime, only to write the data source. ===== The management Node-RED flow ===== On the host we run an additional Node-RED instance (not in Docker, just installed with ''npm install -g --unsafe-perm node-red'' and started via ''systemd''). Its single flow is our deployment console. This is a deliberate example of "what is Node-RED for". For us it acts as a tiny ops UI that wires together "read CSV → write file → run shell command", which would otherwise be a Bash script. The wins are: anyone in the lab can click //Deploy// without remembering shell incantations, every step has its own debug output in the sidebar, and the flow itself documents the deployment pipeline visually. ==== Flow: Deploy / Redeploy ==== Trigger ⟶ //inject// node labelled **Deploy / Redeploy**. - **file in** reads ''/root/nig-workshop-stack/inventory.csv''. It fans out to two branches: * a parallel branch that runs ''rm /etc/nginx/nigs/*'' once, to wipe any stale per-user NGINX snippets before regenerating them; * the main branch, which is delayed 5 seconds (giving the cleanup time to finish) and then parses the CSV. - **csv** parses the file with headers, emitting one message per row. - **change** stashes the parsed row under ''msg.compose_info'' so it survives the next steps. - **function: Generate NGINX Config** builds the per-user NGINX location blocks and assigns the target path: msg.compose_info.nginx_path = `/etc/nginx/nigs/${msg.compose_info.USER_NAME}.conf`; msg.payload = ` location /${msg.payload.USER_NAME}/grafana/ { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://localhost:${msg.payload.GRAFANA_PORT}; } location /${msg.payload.USER_NAME}/grafana/api/live/ { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_pass http://localhost:${msg.payload.GRAFANA_PORT}; } location /${msg.payload.USER_NAME}/node-red { proxy_pass http://localhost:${msg.payload.NODE_RED_PORT}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Real-IP $remote_addr; } `; return msg; The Grafana ''/api/live/'' block is a separate location because Grafana Live uses WebSockets, which need the ''Upgrade'' / ''Connection'' headers and HTTP/1.1. The Node-RED block needs the same WebSocket headers for the editor's live status updates. - **file** writes the generated snippet to the path stored in ''msg.compose_info.nginx_path'', overwriting any previous file for that user. - **exec: systemctl reload nginx.service** picks up the new file. Reload (not restart) keeps existing connections alive. - **change** sets ''msg.payload'' to the user name, which is then appended as an argument to: - **exec: bash /root/nig-workshop-stack/deploy.sh up** — the same script described above. Stdout and stderr are routed to separate debug nodes for visibility. ==== Flow: Delete all ==== Trigger ⟶ //inject// node labelled **Delete all**. Two parallel branches: * ''bash /root/nig-workshop-stack/deploy.sh down'' — stops and removes every Compose stack. * ''rm /etc/nginx/nigs/*'' followed by ''systemctl reload nginx.service'' — wipes the per-user NGINX configs. ==== Why have this at all? ==== For two users you could absolutely just SSH in and run ''./deploy.sh up''. For 20 students during a workshop where the inventory changes a few times an hour, a single-click "regenerate everything" button is much harder to mess up — and the visual flow gives a less-experienced operator a fighting chance at understanding what is being done on their behalf. ===== Access URLs ===== Given the example ''inventory.csv'', after ''./deploy.sh up'' the URLs are: ^ Service ^ URL ^ | Node-RED editor | ''https://nig.example.com/student1/node-red/'' | | Node-RED HTTP endpoints | ''https://nig.example.com/student1/node-red/api/...'' | | Grafana | ''https://nig.example.com/student1/grafana/'' | | Node-RED extra port (direct, no proxy) | ''http://:1881'' | Login uses the ''USER_NAME'' / ''USER_PASSWORD'' from the CSV. The same credentials work for both Node-RED and Grafana — this is convenient for workshops and unacceptable for anything else. ===== Running a local NIG instance from the repository ===== You don't need the reverse proxy to use this repo. The NGINX layer only exists to give many users clean public URLs on one server — for a single local instance you can skip it entirely and just let Docker publish the ports directly to your machine. We've run it this way and it works fine without NGINX. ==== Minimum requirements ==== * Docker * Docker Compose * Python 3 (used by ''deploy.sh'' to generate the instance files) ==== Steps ==== Clone the repo and enter it: git clone https://github.com/EOLab-HSRW/nig-workshop-stack cd nig-workshop-stack Edit ''inventory.csv'' so it has a single row for yourself: USER_NAME,USER_PASSWORD,NODE_RED_PORT,NODE_RED_EXTRA_PORT,GRAFANA_PORT me,mypassword,17601,1881,17701 Bring it up with the deploy script (**not** raw ''docker compose'' — the script generates the bcrypt password hash Node-RED needs to start): ./deploy.sh up Then open: ^ Service ^ URL ^ | Node-RED | ''http://localhost:17601/me/node-red/'' | | Grafana | ''http://localhost:17701/me/grafana/'' | Log in with the ''USER_NAME'' / ''USER_PASSWORD'' from your CSV. Grafana already has the InfluxDB data source wired in via provisioning, so you can start querying immediately. The ''/me/node-red/'' and ''/me/grafana/'' sub-paths are still present locally because the containers are configured with those root paths via the generated env file. They work fine on ''localhost'' — you just type the full path. If the prefix annoys you locally, that's the one thing worth changing in ''compose.yml'' (drop ''NODE_RED_HTTP_ADMIN_ROOT'' and the Grafana sub-path vars), but it's not necessary. ==== The "extra port": inside vs. outside the container ==== A container port and the host port you reach it on are **two different numbers**. Docker //publishes// an internal container port to a host port via the ''host:container'' mapping in ''compose.yml''. For Node-RED's extra port the relevant line is: ports: - "${NODE_RED_EXTRA_PORT}:1881" Read this as ''host:container''. So: * **Inside** the container, anything you bind always listens on **1881**. That number never changes — your flows, a broker, an HTTP listener, all use ''1881'' from the container's point of view. * **Outside**, you reach it on whatever ''NODE_RED_EXTRA_PORT'' you put in the CSV (''1881'' for the first user, ''1882'' for the second, and so on). The outside number //must// be unique per instance so two users don't fight over the same host port; the inside number stays ''1881'' for everyone because each container has its own isolated network namespace. This is exactly why the inventory needs a separate ''NODE_RED_EXTRA_PORT'' column: it's the //external// handle for a //fixed internal// port. When you configure something in Node-RED to listen on a port, use **1881** (the inside number). When you connect to it from your laptop or another machine, use the **outside** number from your CSV. ==== Hosting your own MQTT broker with Node-RED Aedes ==== For testing you often want an MQTT broker without installing one separately. The [[https://flows.nodered.org/node/node-red-contrib-aedes|node-red-contrib-aedes]] node runs a full MQTT broker //inside// Node-RED itself. - In the Node-RED editor, install ''node-red-contrib-aedes'' from the palette manager (//Menu → Manage palette → Install//). - Drop an **aedes broker** node onto a flow and set its port to **1881** — the container-internal extra port. - Point an **mqtt-broker** config (used by ''mqtt in'' / ''mqtt out'' nodes) at ''localhost:1881'', since to the broker node and the client nodes that all live in the same container, ''localhost:1881'' is the broker. - To publish/subscribe from **outside** — e.g. an MQTT client on your laptop or a microcontroller on your network — connect to your host's IP on the **outside** extra port (''1881'', ''1882'', ... whatever you assigned). That's the whole reason the extra port is published. This gives every workshop user a private broker with zero extra infrastructure, reachable both internally (flows) and externally (devices). ==== This setup is not hardened ==== This stack is built for **workshops and local experimentation**, not production. It reuses the same password across Node-RED, Grafana and InfluxDB, runs the end-of-life InfluxDB 1.x line, exposes ports directly, and applies no resource limits or network policies between users. If you want to take any of this further than a workshop, harden the images before exposing them. Good starting points: * **Node-RED** — the official [[https://nodered.org/docs/user-guide/runtime/securing-node-red|Securing Node-RED]] guide covers admin auth, HTTPS, the editor vs. runtime endpoints, and running behind a proxy properly. * **Grafana** — Grafana's [[https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/|security configuration]] docs cover cookie/security settings, disabling the embedded login where SSO is available, and locking down the admin account. * **InfluxDB** — consider moving off the unmaintained 1.x line to a supported InfluxDB release; review the relevant version's [[https://docs.influxdata.com/|security and authentication]] docs. At minimum, never reuse the user password as the admin password the way the workshop defaults do. * **Docker** — apply the general [[https://docs.docker.com/engine/security/|Docker security]] practices and the [[https://github.com/docker/docker-bench-security|Docker Bench for Security]] checks: drop capabilities, set ''read_only'' filesystems where possible, add ''mem_limit'' / ''cpus'', run as a non-root user, and avoid publishing ports you don't actually need on the host. * **Secrets** — replace the plain ''inventory.csv'' passwords with Docker secrets or an external secrets manager, and use unique strong passwords per service rather than one shared value. ===== Further reading ===== * Original lab wiki page this stack is based on: * Repository: [[https://github.com/EOLab-HSRW/nig-workshop-stack|github.com/EOLab-HSRW/nig-workshop-stack]] * [[https://nodered.org/docs/|Node-RED documentation]] * [[https://docs.influxdata.com/influxdb/v1/|InfluxDB 1.x documentation]] * [[https://grafana.com/docs/grafana/latest/|Grafana documentation]] * [[https://eff-certbot.readthedocs.io/|Certbot user guide]]