IoT Stack (Node-RED, Mosquitto, InfluxDB, Grafana) on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of the IoT Stack (Node-RED, Mosquitto, InfluxDB, Grafana) on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images.
Most IoT projects need the same four things, and assembling them is the boring part. This image is those four things already assembled and already wired together:
| Tier | Software | Role |
|---|---|---|
| Ingress | Eclipse Mosquitto 2.1 | the MQTT broker your devices connect to and publish readings into |
| Processing | Node-RED 5.0 | a flow based, low code engine that parses, reshapes and routes each reading |
| Storage | InfluxDB 2.9 | a time series database that keeps every reading on a timeline |
| Presentation | Grafana 13.2 OSS | dashboards over the stored series |
What makes it an appliance rather than four packages is that the path between them is already built. Node-RED ships with a flow that subscribes to the broker, converts incoming JSON readings into InfluxDB line protocol and writes them to the database, and Grafana ships with the InfluxDB data source and a starter telemetry dashboard already provisioned against the same bucket. Publish a reading from any MQTT client and it travels broker to flow to database to chart without you configuring a single connection.
From there the flow editor is where you extend the pipeline: add device specific parsing, alerting, filtering, or extra destinations, using the same drag and drop editor the shipped flow is built in.
The security model, in one paragraph
Each of these four projects ships with an insecure default, and this image closes all four before the virtual machine is ever reachable. Mosquitto ships with an empty password store, so with anonymous access disabled every connection is refused. InfluxDB ships un-onboarded, so its API rejects every call and there is no credential to steal. Node-RED and Grafana are held shut behind a boot gate and cannot start at all until one first boot routine has generated a fresh password, token and secret for each of the four services and written them to a file only root can read. There is no known or default credential anywhere in the image, and no two virtual machines share one. Only four ports are reachable from outside the machine; the database, the flow runtime and the dashboard server are all bound to the loopback interface behind a single TLS terminator whose certificate is also generated on your own machine at first boot.
Prerequisites
- An Azure subscription with permission to create virtual machines, and the Azure CLI installed and signed in if you deploy from the command line.
- An SSH key pair. Azure will use your public key for the
azureuseraccount. - A client for testing. The examples use
mosquitto_pubandmosquitto_sub(Debian and Ubuntu:apt-get install mosquitto-clients; macOS:brew install mosquitto), plus a browser. - Somewhere to keep secrets. First boot generates five credentials and prints them to a root only file; copy them into your password manager.
Step 1: Deploy from the Azure Portal
- In the Azure Portal choose Create a resource and search the Marketplace for IoT Stack (Node-RED, Mosquitto, InfluxDB, Grafana), published by cloudimg.
- Select the offer, then Create.
- On the Basics tab pick your subscription, resource group and region. Set the Size to
Standard_B2msor larger. This is a four service stack: 2 vCPUs and 8 GiB of memory is the recommended floor, and a 4 GiB size will run but leaves no headroom once real ingest starts. - Set Authentication type to SSH public key, leave the username as
azureuser, and paste your public key. - Under Inbound port rules allow SSH (22) for now. Ports 443 and 8883 are opened in Step 3.
- Accept the defaults on the remaining tabs, then Review + create and Create.
Step 2: Deploy from the Azure CLI
Substitute your own resource group, region and virtual machine name.
az group create --name iot-stack-rg --location uksouth
az vm create \
--resource-group iot-stack-rg \
--name iot-stack-vm \
--image cloudimg:nodered-mosquitto-influxdb-grafana:default:latest \
--size Standard_B2ms \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
# Accept the marketplace terms once per subscription, if prompted:
az vm image terms accept --publisher cloudimg \
--offer nodered-mosquitto-influxdb-grafana --plan default
Step 3: Open the ports your devices and dashboards need
The image itself ships no firewall rules. Open only what you need, and scope the source ranges to networks you control rather than leaving them open to the internet.
az network nsg rule create --resource-group iot-stack-rg --nsg-name iot-stack-vmNSG \
--name allow-https --priority 1010 --destination-port-ranges 443 \
--source-address-prefixes <your-mgmt-cidr> --protocol Tcp --access Allow
az network nsg rule create --resource-group iot-stack-rg --nsg-name iot-stack-vmNSG \
--name allow-mqtt-tls --priority 1020 --destination-port-ranges 8883 \
--source-address-prefixes <your-device-cidr> --protocol Tcp --access Allow
Port 80 serves only an unauthenticated /health endpoint and a redirect to HTTPS; you do not need to expose it unless something in your environment health checks over plain HTTP.
Step 4: Retrieve the credentials this virtual machine generated
First boot generates a separate secret for every tier and writes them to a root only file. Nothing is shared between virtual machines, and nothing was baked into the image.
sudo cat /root/nodered-mosquitto-influxdb-grafana-credentials.txt

If that file still says placeholder, rotated at first boot, first boot has not finished yet. Watch it:
sudo journalctl -u iot-stack-firstboot.service --no-pager | tail -30
First boot does not simply create secrets and exit. It proves the whole pipeline before it declares success: it round trips a message through the broker on both the loopback and the TLS listener, confirms anonymous MQTT is refused, onboards InfluxDB, checks the Node-RED runtime answers, confirms Grafana rejects the upstream default password, and then publishes a reading and reads that exact point back out of InfluxDB. If the integration is not working, first boot fails loudly rather than leaving you a half wired appliance.
Step 5: Check the stack came up
systemctl is-active mosquitto influxdb node-red grafana-server nginx
All five should report active. You can also confirm the network posture, which is a deliberate part of this image:
echo "reachable from off-box:"
ss -tlnH | awk '{print $4}' | sed -E 's/%[A-Za-z0-9._-]+//' | grep -vE '^(127\.|\[::1\])' | sort -u
echo "loopback only:"
ss -tlnH | awk '{print $4}' | grep '^127\.0\.0\.1' | sort -u

Exactly four ports answer from outside the machine: 22 (SSH), 80 (health check and redirect), 443 (both web interfaces) and 8883 (MQTT over TLS). The flow runtime, the dashboard server, the database and the plaintext MQTT listener are all bound to 127.0.0.1 and cannot be reached from the network at all.
Step 6: Sign in to the Node-RED flow editor
Browse to https://<vm-ip>/ and sign in as admin with NODE_RED_ADMIN_PASSWORD from Step 4.
The certificate is self signed and generated on your own virtual machine, so your browser will warn on first visit. Step 13 covers replacing it with a certificate authority issued one.

Once signed in you land on the IoT Pipeline flow. This is the wiring that makes the four packages one product.

Reading the flow left to right and top to bottom:
- Publish demo reading is an inject node. Click the button on its left edge to publish a synthetic reading through the real broker, which is the quickest way to see the pipeline move.
- subscribe sensors/# receives everything published under the
sensors/topic tree, whatever the device. - to InfluxDB line protocol parses the JSON payload, turns each numeric field into an InfluxDB field and tags the point with the device name and topic.
- write to InfluxDB posts that line protocol to the local database. Its write token comes from the systemd environment file, so no secret is stored inside the flow.
- append to mqtt-inbox.log and received reading give you a file trail and a live view in the debug sidebar.
Step 7: Publish your first device reading over MQTT with TLS
Devices connect to port 8883 with the broker username and password from Step 4. From the virtual machine itself you can test the whole path immediately:
MQ=$(sudo grep '^MOSQUITTO_PASSWORD=' /root/nodered-mosquitto-influxdb-grafana-credentials.txt | cut -d= -f2-)
mosquitto_pub -h 127.0.0.1 -p 8883 --cafile /etc/cloudimg/tls/server.crt --insecure \
-u cloudimg -P "$MQ" -t sensors/press-line-07 \
-m '{"device":"press-line-07","temperature":74.2,"humidity":21.8}'
echo "published"
From a device or workstation elsewhere, the same call with your own address and the certificate copied off the machine:
mosquitto_pub -h <vm-ip> -p 8883 --cafile ./server.crt \
-u cloudimg -P '<MOSQUITTO_PASSWORD>' -t sensors/press-line-07 \
-m '{"device":"press-line-07","temperature":74.2,"humidity":21.8}'
The broker refuses anything unauthenticated. Both of these are rejected:
if mosquitto_pub -h 127.0.0.1 -t sensors/probe -m x >/dev/null 2>&1; then
echo "UNEXPECTED: an anonymous publish was accepted"; exit 1
fi
echo "anonymous publish correctly refused"
if mosquitto_pub -h 127.0.0.1 -u cloudimg -P wrong-password -t sensors/probe -m x >/dev/null 2>&1; then
echo "UNEXPECTED: a wrong password was accepted"; exit 1
fi
echo "wrong password correctly refused"

Confirm the flow received it:
sleep 3
sudo tail -3 /var/lib/node-red/mqtt-inbox.log
Any JSON object works. Every numeric field becomes an InfluxDB field, so {"device":"tank-3","level":88.5,"flow_rate":12.1} is stored with two fields tagged device=tank-3. A payload with no numeric fields is logged and skipped rather than written.
Step 8: Confirm the reading landed in InfluxDB
The flow writes into the iot bucket of the cloudimg organisation. Read it straight back:
IX=$(sudo grep '^INFLUXDB_ADMIN_TOKEN=' /root/nodered-mosquitto-influxdb-grafana-credentials.txt | cut -d= -f2-)
curl -fsS -XPOST 'http://127.0.0.1:8086/api/v2/query?org=cloudimg' \
-H "Authorization: Token ${IX}" \
-H 'Content-Type: application/vnd.flux' \
-H 'Accept: application/csv' \
--data-binary 'from(bucket:"iot") |> range(start:-15m) |> filter(fn:(r) => r._measurement == "sensor") |> keep(columns:["_time","_field","_value","device"])'

The database is bound to the loopback interface, so that request only works from the machine itself. An unauthenticated one is refused even there:
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8086/api/v2/buckets?org=cloudimg'
Step 9: Open the Grafana dashboard
Browse to https://<vm-ip>/grafana/ and sign in as admin with GRAFANA_ADMIN_PASSWORD from Step 4. Anonymous access is disabled and the upstream admin / admin default is never valid on this image.

Open Dashboards, then the cloudimg folder, then IoT Telemetry (cloudimg). The InfluxDB data source is already provisioned and pointed at the same bucket the flow writes to, so readings appear without any configuration.

The dashboard is editable and is provisioned from a file, so your changes persist in Grafana while the shipped version stays on disk at /var/lib/grafana/dashboards/iot-telemetry.json as a reference to copy from.
Step 10: Connect a real device
Anything that speaks MQTT works: an ESP32 or ESP8266 with the PubSubClient library, a Raspberry Pi with Paho, an industrial gateway, or another Node-RED instance. Point it at:
- Host: your virtual machine's address
- Port:
8883 - TLS: enabled, trusting
/etc/cloudimg/tls/server.crt(copy it to the device, or replace the certificate as in Step 13 so devices trust it through a normal certificate authority) - Username:
cloudimg - Password:
MOSQUITTO_PASSWORDfrom Step 4 - Topic: anything under
sensors/, conventionallysensors/<device-name>
Copy the certificate to your workstation with:
sudo cp /etc/cloudimg/tls/server.crt /tmp/server.crt && sudo chmod 0644 /tmp/server.crt
echo "now fetch /tmp/server.crt with scp"
To give each device its own broker credential rather than sharing one, add users to the password file and restart the broker:
sudo mosquitto_passwd -b /etc/mosquitto/passwd press-line-07 'a-strong-per-device-password'
sudo systemctl restart mosquitto
sudo mosquitto_passwd -D /etc/mosquitto/passwd press-line-07
sudo systemctl restart mosquitto
The example above adds a device user and then removes it again so the machine is left as it was; drop the last two lines when you are provisioning for real. For per device topic restrictions, add an access control list with acl_file in /etc/mosquitto/conf.d/cloudimg.conf.
Step 11: Extend the pipeline in Node-RED
The shipped flow is a starting point, not a fixed appliance. Common next steps, all done in the editor:
- Parse a different payload. Replace the to InfluxDB line protocol function with your own device format, for example a CSV or binary payload from an existing fleet.
- Alert. Add a switch node after subscribe sensors/# and wire it to an email, Slack or webhook node when a value crosses a threshold.
- Route somewhere else as well. MQTT in can feed as many outputs as you like; add a second HTTP request node to forward readings to another system.
- Downsample. Add a delay node in rate limit mode, or use an InfluxDB task, if devices publish faster than you want to store.
Remember to click Deploy in the editor after changing a flow. The write token is available inside function nodes as env.get("INFLUX_TOKEN"), so you never need to paste a secret into a flow.
Step 12: Reach the InfluxDB user interface
InfluxDB has a good built in query and administration interface, but it is deliberately not exposed to the network. Reach it over an SSH tunnel from your workstation:
ssh -L 8086:127.0.0.1:8086 azureuser@<vm-ip>
With that running, browse to http://127.0.0.1:8086/ and sign in as admin with INFLUXDB_ADMIN_PASSWORD. This is where you manage retention, create scoped API tokens for other applications, and build Flux queries interactively.
Step 13: Replace the TLS certificate
The certificate generated at first boot is self signed and unique to this machine. For production, replace it with one from a certificate authority. With a real DNS name pointing at the machine:
sudo apt-get install -y certbot
sudo certbot certonly --standalone -d <your-domain> --agree-tos -m <your-email> --non-interactive
sudo cp /etc/letsencrypt/live/<your-domain>/fullchain.pem /etc/cloudimg/tls/server.crt
sudo cp /etc/letsencrypt/live/<your-domain>/privkey.pem /etc/cloudimg/tls/server.key
sudo install -o root -g mosquitto -m 0644 /etc/cloudimg/tls/server.crt /etc/mosquitto/certs/server.crt
sudo install -o root -g mosquitto -m 0640 /etc/cloudimg/tls/server.key /etc/mosquitto/certs/server.key
sudo systemctl reload nginx && sudo systemctl restart mosquitto
Both nginx and Mosquitto read from /etc/cloudimg/tls/, so replacing that pair covers the dashboards, the flow editor and the device listener. Note that a helper regenerates a self signed pair only when those files are absent, so your real certificate will not be overwritten.
Step 14: What is exposed, and what is not
| Port | Bound to | Serves | Authentication |
|---|---|---|---|
| 22 | all interfaces | SSH | your Azure SSH key |
| 80 | all interfaces | /health and a redirect to HTTPS |
none, by design |
| 443 | all interfaces | Node-RED at /, Grafana at /grafana/ |
per virtual machine passwords |
| 8883 | all interfaces | MQTT over TLS | broker username and password |
| 1883 | loopback | plaintext MQTT for the local flow | broker username and password |
| 1880 | loopback | Node-RED runtime behind nginx | editor authentication |
| 3000 | loopback | Grafana behind nginx | Grafana login |
| 8086 | loopback | InfluxDB API and interface | API token |
The plaintext MQTT listener is loopback only on purpose: it is how Node-RED talks to the broker on the same machine, and exposing it would put device credentials on the wire in clear text. If you must expose it, change the bind address in /etc/mosquitto/conf.d/cloudimg.conf and open the port, but prefer 8883.
Step 15: Retention, disk and backups
InfluxDB stores its data under /var/lib/influxdb on the operating system disk. The shipped bucket has no retention limit, so it grows until you set one. Set a retention period from the InfluxDB interface (Step 12), or from the machine:
IX=$(sudo grep '^INFLUXDB_ADMIN_TOKEN=' /root/nodered-mosquitto-influxdb-grafana-credentials.txt | cut -d= -f2-)
influx bucket list --host http://127.0.0.1:8086 --org cloudimg --token "$IX"
To keep, for example, 90 days, use influx bucket update with --retention 90d and the bucket id from that listing.
For a high ingest deployment, move the database onto its own managed disk: attach and mount a data disk, stop influxdb, copy /var/lib/influxdb across, update bolt-path and engine-path in /etc/influxdb/config.toml, and start it again.
Back up by snapshotting the disk, or with the InfluxDB backup command:
IX=$(sudo grep '^INFLUXDB_ADMIN_TOKEN=' /root/nodered-mosquitto-influxdb-grafana-credentials.txt | cut -d= -f2-)
sudo mkdir -p /var/backups/influxdb
sudo influx backup /var/backups/influxdb/$(date +%F) --host http://127.0.0.1:8086 --org cloudimg --token "$IX"
Send that backup to durable storage rather than leaving it on the machine's own disk.
Your Node-RED flows live in /var/lib/node-red/flows.json and your Grafana dashboards in Grafana's own database at /var/lib/grafana/grafana.db; include both in any backup.
Step 16: Verify the deployment
A single pass that exercises every tier end to end:
CF=/root/nodered-mosquitto-influxdb-grafana-credentials.txt
MQ=$(sudo grep '^MOSQUITTO_PASSWORD=' $CF | cut -d= -f2-)
IX=$(sudo grep '^INFLUXDB_ADMIN_TOKEN=' $CF | cut -d= -f2-)
DEV=verify-$(date +%s)
systemctl is-active mosquitto influxdb node-red grafana-server nginx
mosquitto_pub -h 127.0.0.1 -p 8883 --cafile /etc/cloudimg/tls/server.crt --insecure \
-u cloudimg -P "$MQ" -t "sensors/${DEV}" \
-m "{\"device\":\"${DEV}\",\"temperature\":20.5,\"humidity\":50.0}"
sleep 5
curl -fsS -XPOST 'http://127.0.0.1:8086/api/v2/query?org=cloudimg' \
-H "Authorization: Token ${IX}" \
-H 'Content-Type: application/vnd.flux' \
-H 'Accept: application/csv' \
--data-binary "from(bucket:\"iot\") |> range(start:-10m) |> filter(fn:(r) => r.device == \"${DEV}\")" \
| grep -q "${DEV}" && echo "END-TO-END OK: MQTT -> Node-RED -> InfluxDB"
curl -sk -o /dev/null -w 'grafana %{http_code}\n' https://127.0.0.1/grafana/login
curl -s -o /dev/null -w 'health %{http_code}\n' http://127.0.0.1/health
You should see five active lines, END-TO-END OK, and two 200 responses.
Baked versions and configuration
| Component | Version | Source | Licence |
|---|---|---|---|
| Eclipse Mosquitto | 2.1.2 | ppa:mosquitto-dev/mosquitto-ppa (the project's own Ubuntu channel) |
EPL-2.0 or EDL-1.0, redistributed under EDL-1.0 |
| Node-RED | 5.0.7 | npm, global install | Apache-2.0 |
| Node.js | 24 LTS | NodeSource | MIT |
| InfluxDB | 2.9.1 | repos.influxdata.com |
MIT |
| Grafana OSS | 13.2.1 | apt.grafana.com, the grafana package |
AGPL-3.0-only |
| nginx | 1.24 (Ubuntu) | Ubuntu 24.04 | BSD-2-Clause |
Key paths:
| Path | Contents |
|---|---|
/root/nodered-mosquitto-influxdb-grafana-credentials.txt |
the five per virtual machine secrets |
/etc/mosquitto/conf.d/cloudimg.conf |
broker listeners, authentication and TLS |
/var/lib/node-red/flows.json |
the shipped IoT Pipeline flow |
/etc/cloudimg/node-red.env |
the InfluxDB write token used by the flow |
/etc/influxdb/config.toml |
database bind address and paths |
/etc/grafana/provisioning/ |
the InfluxDB data source and dashboard provider |
/var/lib/grafana/dashboards/iot-telemetry.json |
the shipped dashboard |
/etc/cloudimg/tls/ |
the TLS certificate and key |
/usr/local/sbin/iot-stack-firstboot.sh |
the first boot bootstrap |
Licences and trademarks
This image bundles four independent open source projects, unmodified. Their licence texts ship in the image under /usr/share/cloudimg/licenses/nodered-mosquitto-influxdb-grafana/.
Node-RED is Apache-2.0. Eclipse Mosquitto is dual licensed EPL-2.0 or EDL-1.0 and is redistributed here under the EDL-1.0 arm. InfluxDB 2 is MIT. Grafana is AGPL-3.0-only; this image installs the open source grafana package and does not install Grafana Enterprise or any Enterprise plugin.
Node-RED is a registered trademark of the OpenJS Foundation. Eclipse Mosquitto is a trademark of the Eclipse Foundation AISBL. InfluxDB is a trademark of InfluxData, Inc. Grafana is a registered trademark of Grafana Labs. These names are used nominatively, to identify the unmodified open source software contained in this image. cloudimg is not affiliated with, and this image is not endorsed by, the OpenJS Foundation, the Eclipse Foundation, InfluxData, Inc. or Grafana Labs.
Security notes
- No shared or default credential. Every secret is generated on your own virtual machine at first boot. The image ships an empty broker password store, an un-onboarded database, an unusable editor password hash and a Grafana password nobody holds.
- The upstream defaults are actively refused. Anonymous MQTT is rejected, and Grafana's
admin/admindoes not work on this image. - Four ports, not eight. The database, flow runtime, dashboard server and plaintext broker listener are bound to loopback.
- Per virtual machine TLS. The certificate and private key are generated on your machine; no two deployments share a key. Replace them with a certificate authority issued pair for production (Step 13).
- No telemetry. Node-RED's update and telemetry prompt is answered off in the shipped settings, and Grafana's usage reporting and update checks are disabled, so the appliance does not phone home.
- Scope your network rules. Restrict 443 to the networks that need the dashboards and 8883 to the networks your devices publish from.
- Rotate the broker password and give each device its own credential (Step 10) before putting a fleet on it.
- The base image is fully patched at build time and keeps unattended security upgrades enabled.
Support
This image is published and maintained by cloudimg with 24/7 support. For help with deployment or configuration, contact support through the cloudimg listing on the Azure Marketplace.