OPC Foundation Cloud Initiative Open-Source Reference Solution
- Reference Edge Hardware
- Deploying the IoT Stack
- Accessing the Web UIs
- Managing the Cluster with Portainer
- Onboarding an OPC UA Device
- Onboarding a Non-OPC UA Device
- Querying Data in the InfluxDB Dashboard
- Dashboards with Grafana
- Importing an OPC UA Information Model into InfluxDB (UA Cloud Library)
- Command & Control with UA Cloud Commander
- Accessing the OPC UA Web API (UA Cloud Action)
- Security Analysis (STRIDE)
The reference solution is validated on a compact, fanless industrial PC built around the Raspberry Pi Compute Module 5 (CM5). This platform provides an industrial-grade, DIN-rail-mountable edge gateway suitable for running the OPC Foundation Cloud Initiative open-source reference workloads.
Purchase the following components from Waveshare as a complete kit:
| # | Component | Description | Product Page |
|---|---|---|---|
| 1 | IPCBox-CM5-A | Industrial computer / enclosure kit for the Raspberry Pi Compute Module 5 (aluminum-alloy passive-cooling case, carrier board, dual Gigabit Ethernet, USB, dual HDMI, M.2 M-Key NVMe slot, wide-voltage DC input, RTC). | https://www.waveshare.com/ipcbox-cm5-a.htm |
| 2 | Raspberry Pi Compute Module 5 | The system-on-module (BCM2712 quad-core Cortex-A76). Select a variant without eMMC (not needed), a minimum of 8GB RAM and optionally WiFi to match your needs. | https://www.waveshare.com/compute-module-5.htm |
| 3 | SK NVMe 2242 128G SSD (M.2) | 128 GB M.2 2242 NVMe SSD used for the operating system and application data storage. | https://www.waveshare.com/sk-nvme-2242-128g-ssd-m.2.htm |
| 4 | USB-to-M.2 (NVMe) adapter / enclosure | A USB 3.x adapter that accepts an M-Key M.2 NVMe SSD (2242 compatible). Used to connect the SSD to a separate PC so it can be imaged with Raspberry Pi Imager before final assembly. | https://www.waveshare.com/usb-to-sata.htm |
The operating system is written to the NVMe SSD from a separate PC using the USB-to-M.2 adapter and Raspberry Pi Imager. Do this before assembling the unit.
- Insert the SSD into the USB-to-M.2 adapter. Seat the SK NVMe 2242 128G SSD in the adapter's M-Key slot and secure it, then plug the adapter into a USB 3.x port on your PC. The drive should enumerate as a USB mass-storage device.
- Install Raspberry Pi Imager on the PC from https://www.raspberrypi.com/software/ and launch it.
- Select Device: Pick Raspberry Pi 5.
- Select OS: Click Raspberry Pi OS (other) and pick Raspberry Pi OS Lite (64-bit).
- Select Storage: Pick the SK NVMe SSD presented through the USB-to-M.2 adapter.
⚠️ Double-check you are selecting the SSD and not another drive on your PC — imaging is destructive and erases the selected device. - Customization: Set the hostname, username/password, Wi‑Fi (if used), locale/timezone, and enable SSH on the Services tab. This allows a fully headless first boot.
- Writing: Click WRITE and wait for Imager to write and verify the image.
- Finish: Close the imager, eject the adapter, remove the SSD, and proceed to the Hardware Installation to assemble the device.
Safety: Power off and disconnect the DC input before opening the case. Work on an anti-static surface and handle the CM5 and SSD by their edges.
Image the SSD first. Complete the Software Installation steps below to write Raspberry Pi OS onto the NVMe SSD using the USB-to-M.2 adapter before installing the SSD into the enclosure.
- Open the enclosure. Remove the screws securing the IPCBox-CM5-A cover to expose the CM5 carrier board and the M.2 slot.
- Install the (already-imaged) NVMe SSD. Insert the SK NVMe 2242 128G SSD into the M.2 M-Key slot at roughly a 30° angle, press it down flat, and secure it with the provided M.2 standoff screw.
- Install the Compute Module 5. Align the two high-density connectors on the CM5 with the mating connectors on the carrier board and press firmly and evenly until the module seats fully. Fasten the CM5 mounting screws.
- Apply the thermal interface. Ensure the thermal pad between the CM5 SoC and the aluminum case/heat spreader is in place so the enclosure can act as a passive heatsink.
- Reassemble the enclosure and reinstall the cover screws.
- Connect peripherals. Attach the Ethernet cable, and (optionally, for first-time console access) an HDMI monitor and USB keyboard.
- Connect power. Wire the DC input within the enclosure's rated voltage range (7V to 36V, so a 24V industrial power supply is ideal, or simply use the supplied AC/DC adapter for bench testing) to the power terminal / jack and switch it on. The power/status LED should illuminate and the device will boot from the NVMe SSD.
- Power on the assembled device and log in (via the HDMI/keyboard console or over
SSH using the hostname/credentials configured during imaging):
ssh <username>@<hostname>.local
- Update the system:
sudo apt update && sudo apt full-upgrade -y - Verify the NVMe SSD is the active root device and the CM5 memory is detected:
lsblk free -h
- Confirm network connectivity on the built-in Gigabit Ethernet:
ip addr ping -c3 opcfoundation.org
The reference workload is defined in iot-stack.yaml and runs
on a lightweight single-node Kubernetes cluster (K3s) on the CM5.
iot-stack.yaml deploys the following components, forming an end-to-end pipeline
from industrial protocols to a time-series database:
| Component | Image | Role | Ports |
|---|---|---|---|
| ua-edgetranslator | ghcr.io/opcfoundation/ua-edgetranslator:main |
OPC Foundation UA Edge Translator — connects to southbound assets and translates protocols (LoRaWAN, OCPP, etc.) into an OPC UA information model. Exposes a web UI for configuration. | 4840 (OPC UA server), 5000/5001 (LoRaWAN), 19520/19521 (OCPP), 8080 (web UI) |
| ua-cloudpublisher | ghcr.io/barnstee/ua-cloudpublisher:main |
UA Cloud Publisher — subscribes to OPC UA data (from the edge translator) and publishes it as OPC UA PubSub JSON messages to the MQTT broker. Exposes a web UI for configuration. | 8081 (web UI) |
| ua-cloudcommander | ghcr.io/opcfoundation/ua-cloudcommander:main |
OPC Foundation UA Cloud Commander — the command & control Responder. Subscribes to the Mosquitto commands/# topic for ua-action-request messages, executes OPC UA operations (Read, HistoricalRead, Write, MethodCall) against on-premises OPC UA servers (e.g. the Edge Translator), and publishes ua-action-response messages back to the responses topic. Has no web UI. |
— |
| mosquitto | eclipse-mosquitto:2.0.18 |
Eclipse Mosquitto MQTT broker that carries the OPC UA PubSub data/# and metadata messages between the publisher and Telegraf. Configured via mosquitto-conf (TLS + authentication required with the IOT_USERNAME / IOT_PASSWORD credentials supplied at apply time, exposed to the broker via the MOSQUITTO_USERNAME / MOSQUITTO_PASSWORD env vars, TLS listener on 8883). |
8883 (MQTT/TLS) |
| telegraf | telegraf:1.37-alpine |
Telegraf agent that consumes the MQTT PubSub messages, parses them with the json_v2 parser (defined in the telegraf-conf ConfigMap), and writes them into InfluxDB. Measurements: opcua_pubsub (data) and opcua_metadata (metadata). |
— |
| influxdb | influxdb:2.7 |
InfluxDB 2.7 time-series database that stores the ingested telemetry. Initialized with org iot, bucket mqtt, and admin user set to your IOT_USERNAME. Exposes a web UI (Data Explorer / dashboards). |
8086 (web UI/API) |
| grafana | grafana/grafana:11.2.0 |
Grafana — dashboarding & alerting UI with a pre-provisioned InfluxDB data source (Flux, org iot, bucket mqtt) and a starter dashboard ("OPC UA Telemetry Overview"). |
3000 (web UI) |
| ua-cloudaction | ghcr.io/opcfoundation/ua-cloudaction:main |
OPC Foundation UA Cloud Action — the command & control Requestor. Polls a configured InfluxDB field and, when it crosses a threshold, publishes a ua-action-request (MethodCall) to the Mosquitto commands topic for UA Cloud Commander to execute, closing the digital feedback loop. Has a status web UI and implements the OPC UA Web API for app access. |
8082 (web UI) |
| portainer | portainer/portainer-ce:2.21.4 |
Portainer CE — web UI to manage the single-node K3s cluster (workloads, logs, shells, events). Runs with a cluster-admin-bound ServiceAccount so it manages the cluster in-cluster via the K3s API server. |
9443 (HTTPS UI), 9000 (HTTP UI), 8000 (edge tunnel) |
| influxdb-auth (Secret) | — | Holds the INFLUX_TOKEN used by InfluxDB (admin token), Telegraf (write token), Grafana (query token), and UA Cloud Action (query token). Provided at deploy time via the ${INFLUX_TOKEN} variable. |
— |
| telegraf-conf / mosquitto-conf (ConfigMaps) | — | Configuration for Telegraf (MQTT inputs + InfluxDB output) and Mosquitto respectively. | — |
| ua-cloudpublisher-settings (ConfigMap) | — | Pre-populated settings.json seeded into the UA Cloud Publisher on first start (broker connection, topics, and OPC UA metadata), so no manual broker configuration is required. |
— |
Data flow:
Security note: you choose the credentials at deployment time via the
IOT_USERNAME/IOT_PASSWORDvariables (see Apply the Stack Manifest), used consistently across the Edge Translator, Cloud Publisher, Cloud Commander, Mosquitto, and InfluxDB for demo purposes. Mosquitto uses a self-signed TLS certificate generated at pod startup. Change these and use certificates from a trusted CA before any production or exposed deployment.
Once the device has booted and been updated, install K3s (SSH into the device first):
# Install a single-node K3s cluster (server + agent on the same node)
curl -sfL https://get.k3s.io | sh -
# Verify the node is Ready (may take ~30s)
sudo k3s kubectl get nodesTo use the standard kubectl command and the KUBECONFIG without sudo:
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown "$(id -u):$(id -g)" ~/.kube/config
export KUBECONFIG=~/.kube/config
echo 'export KUBECONFIG=~/.kube/config' >> ~/.bashrc
kubectl get nodesK3s ships with the Traefik ingress controller and a built-in ServiceLB (klipper-lb) load balancer, so the
type: LoadBalancerServices in the manifest are reachable directly on the node's IP address.
-
Copy
iot-stack.yamlonto the device (e.g. withgit clone,scp, or by pasting it into a file opened via a text editor like nano). -
Provide the deployment credentials and InfluxDB token. The manifest references
${IOT_USERNAME},${IOT_PASSWORD}, and${INFLUX_TOKEN}, so set them and substitute them at apply time:# Choose the shared username/password used by the Edge Translator, # Cloud Publisher, Mosquitto broker, and InfluxDB. # NOTE: InfluxDB requires the password to be at least 8 characters. export IOT_USERNAME="myUsername" export IOT_PASSWORD="ChangeMe123" # Generate a random InfluxDB token (or supply your own) export INFLUX_TOKEN="$(openssl rand -hex 32)" # Substitute ONLY these variables and apply. Restricting the variable list is # important so envsubst does not touch the runtime shell variables (e.g. # $MOSQUITTO_USERNAME) used inside the container start-up commands. envsubst '${IOT_USERNAME} ${IOT_PASSWORD} ${INFLUX_TOKEN}' < iot-stack.yaml | kubectl apply -f -
envsubstis part of thegettextpackage (sudo apt install -y gettext-base). Keep the values you chose — you'll reuseIOT_USERNAME/IOT_PASSWORDto log into the web UIs and the broker, and the generatedINFLUX_TOKENto authenticate Telegraf and log into InfluxDB via the API. -
Watch the workloads come up:
kubectl get pods -w kubectl get svc
All pods should reach
Running/Ready, and eachLoadBalancerService should receive anEXTERNAL-IP(the node's IP).If the external IP address for some Kubernetes services shows as
<pending>, use the following command to assign the external IP address of the traefik service: sudo kubectl patch service -p '{"spec": {"type": "LoadBalancer", "externalIPs":[""]}}'.
The ingested OPC UA telemetry is stored by InfluxDB in the mqtt bucket. That
database is backed by a Kubernetes hostPath volume, so the data lives directly
on the Pi's NVMe SSD at:
/influxdb2
Because this is a host directory (not ephemeral pod storage), the telemetry survives pod restarts, redeploys, and reboots.
Related OPC UA telemetry persistence paths are also mapped as hostPath volumes on the Pi:
| Path on the Pi | Component | Contents |
|---|---|---|
/influxdb2 |
InfluxDB | Time-series telemetry, buckets, and InfluxDB config (the primary telemetry store). |
/translator/settings, /translator/nodesets, /translator/pki, /translator/logs |
UA Edge Translator | Configuration, OPC UA nodesets, certificates, and logs. |
/publisher/settings, /publisher/pki, /publisher/logs, /publisher/store |
UA Cloud Publisher | Configuration, certificates, logs, and the Store & Forward message store (queued messages held during broker/connectivity outages). |
/commander/pki, /commander/logs |
UA Cloud Commander | OPC UA client certificates and logs. |
/mosquitto |
Mosquitto | Broker persistence database (mosquitto.db: retained messages and queued messages for persistent sessions). |
/portainer |
Portainer | Portainer database, users, and settings. |
/grafana |
Grafana | Grafana database, users, and user-created dashboards. |
Note: Keep the
INFLUX_TOKENsafe, to read the telemetry stored in InfluxDB in backup scenarios.
Replace <device-ip> with the CM5's IP address (from ip addr or
kubectl get svc). All services are exposed as LoadBalancer types on the node.
| Service | URL | Notes |
|---|---|---|
| UA Edge Translator | http://<device-ip>:8080 |
Configure southbound asset connections and the OPC UA information model. Log in with the IOT_USERNAME / IOT_PASSWORD you set (exposed via the manifest OPCUA_USERNAME / OPCUA_PASSWORD env vars). |
| UA Cloud Publisher | http://<device-ip>:8081 |
Configure which OPC UA nodes to publish and the MQTT broker target (mosquitto.default.svc.cluster.local:8883, TLS). Log in with the IOT_USERNAME / IOT_PASSWORD you set (exposed via the manifest PUBLISHER_USERNAME / PUBLISHER_PASSWORD env vars). |
| InfluxDB | http://<device-ip>:8086 |
Time-series UI, Data Explorer, and dashboards. Log in with the IOT_USERNAME / IOT_PASSWORD you set (org iot, bucket mqtt). |
| Portainer | https://<device-ip>:9443 |
Kubernetes management UI for the K3s cluster. On first access you set the admin password (see Managing the Cluster with Portainer). |
| Grafana | http://<device-ip>:3000 |
Dashboards & alerting. Log in with the IOT_USERNAME / IOT_PASSWORD you set. The InfluxDB data source and a starter dashboard are pre-provisioned (see Dashboards with Grafana). |
| UA Cloud Action | http://<device-ip>:8082 |
Status UI for the automated feedback loop (data-source, broker, and Commander connectivity) and OPC UA Web API. Log in with the IOT_USERNAME / IOT_PASSWORD you set (see Automated Feedback Loop with UA Cloud Action). |
To keep both UIs reachable on the single node,
8081 (mapped to the container's 8080) while the Edge Translator stays on 8080. No extra steps are needed — just browse to :8080 and :8081 respectively.
Portainer CE provides a web UI to inspect and manage everything running on the
single-node K3s cluster (deployments, pods, logs, container shells, events, and
volumes). It is deployed by iot-stack.yaml and is pre-wired to manage the
cluster it runs in — no manual endpoint configuration is required.
How the K3s connection works:
- The manifest creates a
portainer-sa-clusteradminServiceAccount and a ClusterRoleBinding to the built-incluster-adminrole, then runs the Portainer pod under that ServiceAccount. Portainer therefore talks to the K3s API server in-cluster using the mounted ServiceAccount token — it manages the local Kubernetes environment out of the box. - Portainer data (users, settings) is persisted on the Pi at
/portainer.
First-time setup:
- Browse to
https://<device-ip>:9443(accept the self-signed certificate warning) within a few minutes of the pod starting.For security, Portainer disables initial admin creation if you don't complete it shortly after startup. If you see a timeout message, restart the pod:
kubectl rollout restart deployment/portainer. - Create the admin user and password.
- On the environments page, select the local Kubernetes environment (already connected via the in-cluster ServiceAccount) and click Live connect.
- You can now browse the
defaultnamespace to see the Edge Translator, Cloud Publisher, Cloud Commander, Mosquitto, Telegraf, and InfluxDB workloads, view their logs, exec into containers, and monitor cluster resources.
Use this path when the device already speaks OPC UA (including data that the Edge Translator has already exposed).
- Open the UA Cloud Publisher UI at
http://<device-ip>:8081(log in with theIOT_USERNAME/IOT_PASSWORDyou set, exposed via the manifestPUBLISHER_USERNAME/PUBLISHER_PASSWORDenv vars). - Go to OPC UA Connect and enter the device's OPC UA endpoint URL, e.g.:
- The Edge Translator:
opc.tcp://<device-ip> - A standalone OPC UA device:
opc.tcp://<device-address>:<port>
- The Edge Translator:
- Set the security policy and, if required, the credentials (
OPCUA_USERNAME/OPCUA_PASSWORD, i.e. theIOT_USERNAME/IOT_PASSWORDyou set) and click Connect.On first connect the client and server exchange certificates. If the UA Cloud Publisher connection is rejected, trust its certificate in the OPC UA device and retry.
- Browse the device's address space and select the variable nodes you want to publish and click the 'publish' button.
- The Publisher immediately begins sending OPC UA PubSub JSON messages to Mosquitto (
data/#), and the metadata describing each dataset to themetadatatopic. Confirm data is flowing by checking the InfluxDBmqttbucket (see Querying Data in the InfluxDB Dashboard).
The UA Edge Translator uses W3C Web of Things (WoT) Thing Descriptions (TDs) to model a non-OPC UA asset (e.g. Modbus TCP, LoRaWAN, OCPP, or an HTTP/REST device) and expose its data points as OPC UA nodes. Once mapped, the device is published exactly like a native OPC UA device.
-
Open the UA Cloud Publisher UI at
http://<device-ip>:8081(log in with theIOT_USERNAME/IOT_PASSWORDyou set, exposed via the manifestPUBLISHER_USERNAME/PUBLISHER_PASSWORDenv vars). -
Go to UA Edge Translator and provide a WoT Thing Description for your asset. The Thing Description declares:
- the protocol binding (e.g.
modbus+tcp://<device-ip>:502, an OCPP/ LoRaWAN endpoint, or an HTTP base URL), - the device's properties/telemetry (each becomes an OPC UA variable), and
- the datatype, access, and addressing (e.g. Modbus register/coil, unit id) for each property.
If your device vendor didn't supply a Thing Description, see Generating WoT Thing Descriptions from PLC Engineering Tools. and Generating a WoT Thing Description for a Foxed-Function Asset on how to generate a Thing Description.
Uploaded TDs are persisted on the Pi under
/translator/settings. The Edge Translator connects to the asset over its native protocol and instantiates the mapped data points as OPC UA nodes in its address space (served atopc.tcp://<device-ip>:4840).All asset tags specified in the Thing Description as properties are automatically published.
- the protocol binding (e.g.
InfluxDB 2.x includes a built-in UI with a Data Explorer and Dashboards that query data using the Flux language.
- Browse to
http://<device-ip>:8086and sign in with theIOT_USERNAME/IOT_PASSWORDyou set. - Go to Data Explorer (the graph icon) to build and preview queries, or Dashboards → Create Dashboard → Add Cell to pin a query to a dashboard.
- Data written by Telegraf lands in the
mqttbucket under the measurementsopcua_pubsub(live values) andopcua_metadata(schema/metadata).
Example Flux query (paste into a Data Explorer / dashboard cell in
Script Editor mode). This joins the live opcua_pubsub data with the
opcua_metadata schema on datasetWriterId so each series is labelled with its
human-readable metadata name:
data =
from(bucket: "mqtt")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) =>
r._measurement == "opcua_pubsub" and
r._field == "Payload_VoltageL-N_Value_C"
)
|> keep(columns: ["_time", "_value", "datasetWriterId"])
|> group(columns: ["datasetWriterId"])
|> sort(columns: ["_time"])
meta =
from(bucket: "mqtt")
|> range(start: -30d)
|> filter(fn: (r) =>
r._measurement == "opcua_metadata" and
r._field == "cfgMajor"
)
|> group(columns: ["datasetWriterId"])
|> last()
|> keep(columns: ["datasetWriterId", "metaName"])
|> group(columns: ["datasetWriterId"])
join(tables: {d: data, m: meta}, on: ["datasetWriterId"], method: "inner")
|> map(fn: (r) => ({
_time: r._time,
_value: float(v: r._value),
_source: r.datasetWriterId,
_tagName: r.metaName
}))
Tip: Use the Data Explorer's visual Query Builder to discover the exact
_fieldnames available (they mirror the OPC UA PubSub payload keys, e.g.Payload_<NodeName>_Value), then switch to the Script Editor to refine the Flux and save the cell to a dashboard. Set the cell's refresh interval and time range at the top of the dashboard for live monitoring.
Grafana is deployed by iot-stack.yaml as an alternative to InfluxDB's
built-in dashboards, with richer visualization, templating, and alerting. It comes
pre-provisioned so no manual setup is needed beyond logging in.
What the manifest provisions automatically:
- InfluxDB data source (
grafana-datasourcesConfigMap) — points athttp://influxdb.default.svc.cluster.local:8086using the Flux query language, orgiot, and default bucketmqtt. The query token is injected from theinfluxdb-authSecret via theINFLUX_TOKENenvironment variable (interpolated into the provisioned data source at startup). - Dashboard provider (
grafana-dashboard-providerConfigMap) — loads any dashboards found under/var/lib/grafana/dashboards. - Starter dashboard (
grafana-dashboardsConfigMap) — OPC UA Telemetry Overview (uidopcua-overview) with a time-series panel ofopcua_pubsubvalues, an ingest-rate stat, an active-dataset-writers stat, and apublishertemplate variable for filtering.
Usage:
- Browse to
http://<device-ip>:3000and log in with yourIOT_USERNAME/IOT_PASSWORD(Grafana admin credentials set viaGF_SECURITY_ADMIN_USER/GF_SECURITY_ADMIN_PASSWORD). - Open Dashboards → OPC UA Telemetry Overview to see live data flowing from
the
mqttbucket. - Use the InfluxDB data source in Explore or when adding new panels; write Flux queries exactly as in the InfluxDB dashboard section.
- Grafana settings and any dashboards you create are persisted on the Pi at
/grafana. (The provisioned data source and starter dashboard are managed by the ConfigMaps and re-applied on restart.)
Note: the starter dashboard's numeric time-series panel assumes numeric fields (e.g.
Payload_<NodeName>_Value). Adjust the panel's Flux filter to match the exact_fieldnames your assets publish.
You can pre-load the full set of variables an OPC UA server could expose — not just the ones currently being published — by importing its Information Model from the OPC Foundation UA Cloud Library into InfluxDB.
Each model variable is written as a placeholder point (field status="[Future]")
into a dedicated opcua_model measurement in the mqtt bucket, so you can see
every potential node alongside the live opcua_pubsub values.
A separate measurement is used (rather than mixing into
opcua_pubsub) because InfluxDB fields are single-typed — the model's placeholder is a string, while live telemetry values are numeric.
The importer is provided as an on-demand Kubernetes Job in
import-opcua-model.yaml. It uses a small
standard-library Python script that downloads the model's NodeSet2 XML from the
Cloud Library REST API, extracts every UAVariable, and writes them to InfluxDB
using the token from the existing influxdb-auth Secret.
Steps:
-
Register (free) at the UA Cloud Library and note the model id of the model you want (visible in its Explorer URL — e.g. the
Stationnodeset is1627266626). -
Run the import Job, supplying your Cloud Library credentials and the model id (substituted at apply time):
export UACLOUDLIB_USERNAME="myUser" export UACLOUDLIB_PASSWORD="myPass" export UACLOUDLIB_MODEL_ID="1627266626" kubectl delete job import-opcua-model --ignore-not-found envsubst < import-opcua-model.yaml | kubectl apply -f - kubectl logs -f job/import-opcua-model
The log prints how many variables were imported.
-
Query the imported model in the InfluxDB Data Explorer or Grafana:
from(bucket: "mqtt") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "opcua_model") |> filter(fn: (r) => r._field == "displayName") |> keep(columns: ["_value", "nodeId", "dataType", "namespaceUri", "model"])
The Cloud Library endpoint (
uacloudlibrary.opcfoundation.org) must be reachable from the cluster for the import Job to run. The Job auto-cleans up one hour after completion (ttlSecondsAfterFinished).
While the Publisher → Telegraf → InfluxDB path handles read-only telemetry, UA Cloud Commander adds a command & control path so a cloud (or local) application can remotely read, write, call methods on, and historically read OPC UA nodes on the edge — all over the same Mosquitto broker.
Cloud Commander implements the OPC UA PubSub Actions request/response pattern
(OPC 10000-14). It acts as the Responder: it subscribes to the commands/#
topic, executes the requested OPC UA operation against an on-premises OPC UA
server (e.g. the Edge Translator at opc.tcp://<device-ip>:4840), and publishes
the result to the responses topic.
ua-cloudcommander is deployed by iot-stack.yaml and connects to Mosquitto via
these environment variables (no web UI — it is configured entirely through env):
| Variable | Value | Purpose |
|---|---|---|
BROKERNAME |
mosquitto.default.svc.cluster.local |
In-cluster Mosquitto Service. |
BROKERPORT |
8883 |
TLS MQTT port. |
USE_TLS |
true |
Mosquitto requires TLS. |
CLIENTNAME |
UACloudCommander |
MQTT client id / Responder PublisherId. |
TOPIC |
commands/# |
Topic it subscribes to for ua-action-request messages. |
RESPONSE_TOPIC |
responses |
Default topic for ua-action-response messages (used when a request omits ResponseAddress). |
USERNAME / PASSWORD |
${IOT_USERNAME} / ${IOT_PASSWORD} |
Broker credentials (same as the rest of the stack). |
UA_USERNAME / UA_PASSWORD |
${IOT_USERNAME} / ${IOT_PASSWORD} |
Credentials used to sign in to the target OPC UA servers. |
The OPC UA client certificate and logs are persisted on the Pi under
/commander/pki and /commander/logs.
Self-signed TLS: Mosquitto uses a self-signed certificate generated at pod startup. If Cloud Commander rejects the TLS handshake, mount/trust the broker's CA certificate in the Commander container (or use a trusted-CA certificate for Mosquitto) as recommended in the security note above.
Publish a ua-action-request NetworkMessage to the commands topic and listen
on responses for the reply. For example, to read a node (from a machine that
can reach the broker, using the stack credentials over TLS):
# Subscribe for responses in one terminal
mosquitto_sub -h <device-ip> -p 8883 --cafile ca.crt \
-u "$IOT_USERNAME" -P "$IOT_PASSWORD" -t responses
# Publish a Read request in another terminal
mosquitto_pub -h <device-ip> -p 8883 --cafile ca.crt \
-u "$IOT_USERNAME" -P "$IOT_PASSWORD" -t commands -m '{
"MessageId": "32235f26-4a3a-4a56-9f1f-2b6f8a2f0a11",
"MessageType": "ua-action-request",
"PublisherId": "MyCloudApp",
"Timestamp": "2022-11-28T12:01:00.0923534Z",
"RequestorId": "MyCloudApp",
"TimeoutHint": 15000,
"Messages": [
{
"DataSetWriterId": 1,
"ActionTargetId": 1,
"RequestId": 1,
"ActionState": 1,
"Payload": {
"Endpoint": "opc.tcp://<device-ip>:4840",
"NodeId": "http://opcfoundation.org/UA/Station/;i=123"
}
}
]
}'The ActionTargetId selects the operation: 1 = Read, 2 = HistoricalRead,
3 = Write, 4 = MethodCall. Cloud Commander replies on responses with a
ua-action-response message echoing RequestorId / RequestId and containing
the Result (or an Error). See the
UA Cloud Commander documentation
for the full payload schema of each operation.
UA Cloud Action is the automated Requestor counterpart to Cloud Commander.
Instead of a human publishing a command, it polls telemetry and reacts: on a
15-second loop it queries a configured value and, when it crosses a threshold,
publishes a ua-action-request (a MethodCall) to the commands topic — which
Cloud Commander executes on the target OPC UA server. This closes an edge-local
digital feedback loop entirely on the Pi.
In this reference stack it is deployed (iot-stack.yaml) to read from InfluxDB
and drive Commander over Mosquitto:
| Variable | Value | Purpose |
|---|---|---|
DATA_SOURCE |
InfluxDB |
Use InfluxDB as the trigger source (instead of Azure Data Explorer). |
INFLUX_URL |
http://influxdb.default.svc.cluster.local:8086 |
InfluxDB endpoint. |
INFLUX_ORG / INFLUX_BUCKET |
iot / mqtt |
Org and bucket to query. |
INFLUX_MEASUREMENT |
opcua_pubsub |
Measurement to query. |
INFLUX_FIELD |
Payload_Pressure_Value |
Numeric field to evaluate — change to match your assets. |
INFLUX_THRESHOLD |
4000 |
Trigger when the latest value exceeds this. |
INFLUX_RANGE |
-1m |
Look-back window for the latest value. |
INFLUX_TOKEN |
(from influxdb-auth) |
Query token. |
MESSAGING_PLATFORM |
MQTT |
Reach Commander over MQTT (not Kafka). |
MQTT_TARGET |
UACloudCommander |
Send the full OPC UA PubSub ActionRequest envelope. |
BROKER_NAME / MQTT_PORT |
mosquitto… / 8883 |
Mosquitto broker (TLS). |
MQTT_USE_TLS / MQTT_TLS_INSECURE |
true / true |
TLS with the self-signed broker cert (verification skipped, like Telegraf). |
BROKER_USERNAME / BROKER_PASSWORD |
${IOT_USERNAME} / ${IOT_PASSWORD} |
Broker credentials. |
TOPIC / RESPONSE_TOPIC |
commands / responses |
The topics Cloud Commander subscribes/replies on. |
UA_SERVER_ENDPOINT, UA_SERVER_METHOD_ID, UA_SERVER_OBJECT_ID, … |
(placeholders) | The OPC UA method Cloud Commander invokes on trigger — set these to a real method on your target server (e.g. the Edge Translator). |
InfluxDB data source: support for querying InfluxDB was added to UA Cloud Action for this stack (the upstream app queries Azure Data Explorer). The
DATA_SOURCE=InfluxDBbranch runs a Flux query for the latestINFLUX_FIELDvalue and compares it toINFLUX_THRESHOLD. SetINFLUX_FIELDto a numeric field your assets actually publish (discover exact names in the InfluxDB Data Explorer or Grafana), otherwise no trigger will fire.
A small status UI is available at http://<device-ip>:8082 (log in with your
IOT_USERNAME / IOT_PASSWORD), showing connectivity to the data source, the
broker, and Cloud Commander.
In addition to the MQTT-based feedback loop, UA Cloud Action exposes an OPC UA Web API — a RESTful, OpenAPI-based HTTP interface to the standard OPC UA services defined in OPC UA Part 4. This lets you build custom applications — dashboards, mobile apps, analytics jobs, or backend integrations — that talk to the edge's OPC UA servers over plain HTTP/JSON, without embedding a native OPC UA stack.
Implemented services: this reference deployment implements the
Read,HistoryRead(historical read), andBrowseservices of the OPC UA Web API. Other services (Write,Call, etc.) are not implemented due to security considerations — requests to them are not available. Use UA Cloud Commander (the MQTT command path) forWrite/Calloperations in the meantime.
Note: the Web API is being implemented in UA Cloud Action. The endpoints and auth described below track the OPC UA Web API specification; confirm the exact routes against the running service's OpenAPI/Swagger document.
The API is served by the ua-cloudaction container and reachable through its
Service at:
http://<device-ip>:8082
- The OpenAPI/Swagger definition (e.g.
http://<device-ip>:8082/swagger) describes every available route and schema — point your tooling at it to explore or generate clients. In the interactive Swagger UI, use the Authorize button to supply the Basic credentials before invoking the/opcua/readand/opcua/historyreadoperations. - Authentication is mandatory. The UA Cloud Action web UI and Web API require
HTTP Basic authentication on every request — there is no anonymous access.
Supply the
IOT_USERNAME/IOT_PASSWORDcredentials (set via the manifest'sADMIN_USERNAME/ADMIN_PASSWORD) in the HTTPAuthorization: Basic <base64>header. Unauthenticated requests receive401 Unauthorizedwith aWWW-Authenticate: Basicchallenge.The OPC UA Web API specification also allows bearer/JWT tokens in the
Authorizationheader (the reference gateway uses OAuth2 JWTs); Basic auth is what this reference deployment mandates. - The API is rate limited per client IP using a fixed window. The limit and
window are configurable via the
RATE_LIMIT_PERMITandRATE_LIMIT_WINDOW_SECONDSenvironment variables (defaulting to 60 requests per 60 seconds iniot-stack.yaml). Requests exceeding the limit receive429 Too Many Requests. - Behind the OPC UA Web API, UA Cloud Action forwards the requested service to the
target OPC UA server (e.g. the Edge Translator at
opc.tcp://ua-edgetranslator.default.svc.cluster.local:4840).
Use the OPC Foundation
UA Web API Starter Kit
as the starting point. Its UaWebApiClient folder contains ready-to-run sample
clients in several environments that call the OPC UA Web API using pre-built
stubs (classes/constants for the OPC UA services, BrowseNames, and
NodeIds):
| Sample client | Language / stubs |
|---|---|
UaWebApiClient/csharp |
C# — DotNet stubs |
UaWebApiClient/nodejs |
JavaScript — TypeScript stubs |
UaWebApiClient/react |
TypeScript (browser) — TypeScript stubs |
UaWebApiClient/python |
Python — Python stubs |
Typical workflow to build your own app:
- Clone the starter kit and pick the sample client that matches your stack:
git clone https://github.com/OPCFoundation/UA-WebApi-StarterKit.git cd UA-WebApi-StarterKit/UaWebApiClient - Point the client at your Web API endpoint — set its base URL to
http://<device-ip>:8082(the UA Cloud Action Web API) and set theAuthorization: Basic <base64(user:pass)>header (mandatory) using yourIOT_USERNAME/IOT_PASSWORD. - Use the pre-built stubs to invoke OPC UA services with typed
requests/responses instead of hand-crafting JSON. In this deployment the
available operations are
Read,HistoryRead, andBrowse(reading current and historical variable values and browsing the address space);WriteandCallare not yet exposed by the Web API. - (Optional) Generate model-specific classes. For DataTypes from a custom information model, convert its NodeSet to an OpenAPI schema with the Opc.Ua.ModelCompiler, then generate typed classes with the OpenAPI Generator — exactly as the starter kit does — so your app understands the model's structured values.
- Iterate from a sample — start from the closest
UaWebApiClientsample and extend it into your own dashboard, service, or integration.
Because the interface is standard OpenAPI, you can also generate a client in any other language supported by the OpenAPI Generator directly from the service's OpenAPI document, rather than using the pre-built stubs.
This section applies the STRIDE threat-modeling framework (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) to the reference stack. It is intended to help you understand the residual risks of the demo configuration and what to change before an internet-exposed or production deployment.
Important: the reference manifest is optimized for a self-contained, single-node demo. It ships with convenience defaults (shared credentials, a self-signed broker certificate generated at pod start,
LoadBalancerservices bound to the node IP, and permissive TLS verification in Telegraf). These are not appropriate for production as-is — see Production Hardening Recommendations.
[Field devices] --(OPC UA / Modbus / LoRaWAN / OCPP / HTTP)--> [UA Edge Translator]
| |
| Boundary A: device <-> edge | (OPC UA server :4840)
v v
[UA Cloud Publisher] --(MQTT/TLS :8883, user/pass)--> [Mosquitto] --(MQTT/TLS)--> [Telegraf] --(HTTP + token)--> [InfluxDB]
^ ^ ^ ^ ^
| [UA Cloud Commander] -+ | (commands/responses) | |
| [UA Cloud Action] --------+--(reads InfluxDB threshold, publishes commands)--------+ |
| [Grafana] -----+ (query token)
| [Model importer Job] ----+ (writes opcua_model)
| Boundary B: operator <-> web UIs (:8080/:8081/:8082/:8086/:3000/:9443, basic auth) |
+----------- Boundary C: node/cluster host (K3s + Portainer cluster-admin, hostPath volumes) -----------------------+
Key assets: the telemetry data (in transit and at rest in InfluxDB), the shared
IOT_USERNAME / IOT_PASSWORD credentials, the INFLUX_TOKEN, the UA Cloud
Library credentials used by the import Job, the broker's private key, the
Portainer cluster-admin ServiceAccount token (full control of the cluster), and
the K3s node itself (root of trust for all hostPath data).
| STRIDE category | Representative threats in this stack | Mitigations already in place | Residual risk / gaps |
|---|---|---|---|
| Spoofing (identity) | A rogue client impersonates the Publisher or UA Cloud Commander/Action to the broker; an attacker impersonates a web UI user (Translator, Publisher, Grafana, UA Cloud Action, or Portainer); a fake OPC UA server feeds the Publisher; a forged ua-action-request triggers an OPC UA method; an unauthenticated caller hits the OPC UA Web API. |
MQTT broker requires username/password (allow_anonymous false); all web UIs (:8080/:8081/:8082/:3000/:9443) require login; the UA Cloud Action web UI and OPC UA Web API mandate HTTP Basic authentication on every request (no anonymous access); OPC UA supports certificate exchange between Publisher/Commander and server. |
Single shared credential set across all components (including Grafana/Portainer admin and the Web API); Basic-auth credentials are only as safe as the transport (send over TLS in production); no per-service identities or mutual TLS (mTLS); broker does not authenticate clients by certificate; any client that can publish to commands can drive Commander. |
| Tampering (integrity) | Modification of telemetry in transit; tampering with hostPath config/cert files on the node; editing the ConfigMaps; altering the imported opcua_model data or the model importer script; a malicious command writing/actuating an OPC UA node via Commander. |
MQTT is carried over TLS (8883); config is delivered via Kubernetes ConfigMaps/Secrets; Commander/Action send spec-compliant OPC UA PubSub Action envelopes. | Telegraf and UA Cloud Action use TLS verification skip (insecure_skip_verify / MQTT_TLS_INSECURE=true), so a man-in-the-middle with any cert is accepted; hostPath volumes (/influxdb2, /translator/*, /publisher/*, /commander/*, /portainer, /grafana) are writable by anyone with node access; no message signing on payloads; Commander performs Writes/MethodCalls with no per-action authorization. |
| Repudiation (auditability) | An operator changes a device mapping, publish set, Grafana dashboard, or issues a command and denies it; no record of who logged in or who imported a model. | Component logs are written to hostPath logs directories and pod stdout; Portainer records some cluster events. |
No centralized, tamper-evident audit log; shared credentials make actions unattributable to an individual; command/action requests and model imports are not attributably logged; no log shipping or retention policy. |
| Information disclosure (confidentiality) | Sniffing telemetry; reading credentials from the manifest; exposed dashboards (Grafana, Portainer, UA Cloud Action) on the node IP; leaking the UA Cloud Library credentials used by the import Job. | MQTT is encrypted with TLS; INFLUX_TOKEN is stored in a Kubernetes Secret; credentials are supplied at apply time (not committed to git). |
Credentials (including UA Cloud Library and Grafana/Portainer admin) are injected as plain-text env vars (visible via kubectl describe/exec); Kubernetes Secrets are base64, not encrypted at rest by default; self-signed broker cert offers encryption but no server-identity assurance; all UIs are exposed on the node IP with no network policy. |
a single node is a single point of failure; broker persists to hostPath (/mosquitto), reducing message loss on restart though the single node remains a SPOF; UA Cloud Action's rate limit still needs tuning for your environment. |
|||
| Elevation of privilege (authorization) | Container escape to the node; a compromised pod reading another component's data via shared host paths; using the InfluxDB admin token for full DB control; abusing Portainer's cluster-admin ServiceAccount to take over the whole cluster; using Commander/Action to reach and control OT devices. |
Distinct container images per component; nodeSelector pins workloads to Linux; the importer Job uses restartPolicy: Never. |
Containers run with default (often root) user and no securityContext; no NetworkPolicy isolation between pods; the InfluxDB token is an all-powerful admin token; Portainer is bound to cluster-admin, so compromising it compromises the cluster; Commander bridges IT→OT with method-call/write capability and no fine-grained authorization; no RBAC scoping for the workloads. |
The following changes move the stack from a demo toward a production-grade deployment. Prioritize the items marked (High).
- Use unique, per-service credentials (High). Replace the single shared
IOT_USERNAME/IOT_PASSWORDwith distinct identities for the Translator UI, Publisher UI, broker client, and InfluxDB admin. Store them in a real secrets manager (e.g. HashiCorp Vault, Sealed Secrets, or an external secrets operator) rather than plain-text env vars. - Deploy trusted TLS certificates and enforce verification (High). Replace
the self-signed, pod-generated broker certificate with one from a trusted CA
(e.g. via cert-manager). Remove
insecure_skip_verify = truefrom the Telegraf MQTT inputs and pin the broker CA so man-in-the-middle attacks are prevented. Enable TLS on the web UIs (terminate at an ingress). - Enable mutual TLS (mTLS) or per-client auth on the broker. Configure Mosquitto to authenticate publishers/subscribers by client certificate in addition to username/password, and use ACLs to restrict which topics each client may publish/subscribe to.
- Scope the InfluxDB token (High). Do not use the all-powerful admin token
for Telegraf. Create a dedicated write-only token limited to the
mqttbucket, and separate read tokens for dashboards. - Restrict network exposure (High). Do not expose
LoadBalancerservices directly on the node IP. Front the web UIs with an authenticating reverse proxy/ingress, place the broker and database on an internal network only, and add KubernetesNetworkPolicyrules so pods can only reach the peers they need. - Harden the pods. Add a
securityContext(runAsNonRoot: true,readOnlyRootFilesystem: true, drop Linux capabilities,allowPrivilegeEscalation: false) and set CPU/memoryrequests/limitsto contain resource-exhaustion and blast radius. - Protect data at rest. Enable encryption at rest for the node's disk
(
/influxdb2and the otherhostPathvolumes) and for Kubernetes Secrets (e.g. a KMS provider or an encrypted etcd). Replace ad-hochostPathvolumes with managedPersistentVolumeClaimswhere possible. - Add auditing and monitoring. Ship component and access logs to a central, tamper-evident store; enable Kubernetes audit logging; and add alerting on authentication failures, pod restarts, and disk usage.
- Manage capacity and availability. Set InfluxDB retention policies to bound
growth, back up
/influxdb2regularly, and consider multi-node/HA for the broker and database to remove the single-point-of-failure. - Keep software patched. Pin and regularly update the container image versions, apply OS/K3s security updates, and scan images for known vulnerabilities as part of your release process.
- Scope Portainer's cluster access (High). The demo binds Portainer to the
built-in
cluster-adminrole. For production, grant it a least-privilegeRole/ClusterRolelimited to the namespaces and resources operators actually manage, protect its UI behind the ingress, and enforce strong, per-user Portainer accounts (not the shared credentials). - Authorize and throttle the command/control path. Restrict who can publish
to the
commandstopic (broker ACLs) and validate/allow-list the OPC UA methods and nodes UA Cloud Commander may Write/Call. UA Cloud Action includes a built-in rate limiter on its actuation, so a faulty threshold or spoofed value cannot drive OT devices uncontrollably; tune its limit for your environment. Treat the UA Cloud Library import credentials as secrets and restrict the import Job's egress.

