Skip to content
Martin Mwiti
All documentation

Dopesilicon

Architecture of a full-stack cloud IoT platform — an MQTT broker, a Node.js ingestion and REST backend, an InfluxDB time-series store, and a React dashboard behind an nginx reverse proxy.

MQTTMosquittoNode.jsExpressInfluxDBReactDockerView project

Overview

Dopesilicon is a modular cloud IoT platform. Devices publish telemetry over MQTT, a backend ingests those messages into InfluxDB, and a React dashboard visualizes them through a REST API. Every component runs as a container, which makes the whole stack deployable to a single server or to cloud container platforms without changing application code.

The platform treats a device as a set of named measurements. The plumbing between the device and the dashboard is generic: the backend never parses device payloads directly, it routes whole topics into the time-series store. Adding a new measurement is a data-model change, not an architecture change.

                    ingress (transport)
                        │
      ┌─────────────────┼──────────────────┐
      ▼                 ▼                  ▼
 publisher ──► Mosquitto broker ──► backend (Node.js)
      ▲                 │                  │  writes (Points)
      │                 │                InfluxDB
      │            nginx proxy            │  reads (Flux)
 dashboard (React) ◄──────────────────────┘

Components

The stack (docker-compose.yml) runs five services on an isolated bridge network:

ServiceImage / stackPort(s)Role
mosquittoEclipse Mosquitto1883, 9001MQTT broker (TCP + WebSocket)
back-endNode.js 20 + Express3000MQTT subscriber, REST API, DB access
influxdbInfluxDB 2.x8086Time-series storage
front-endReact 18 (nginx-served build)80 (internal)Web dashboard
nginxnginx:stable-alpine80, 1883Reverse proxy, MQTT stream

influxdb and mosquitto persist to named volumes so data survives container restarts; the backend reads its configuration from an .env file mounted through Compose.

Data Model

The data model lives in back-end/constants.js and is shared by every database module:

const measurements = {
  temperature: "temperature",
  sound: "sound",
  heart_rate: "heart",
  sleep: "sleep",
  walking: "walking",
  jogging: "jogging",
  steps: "steps",
  biking: "biking",
  idling: "idling",
  oxygen: "oxygen",
};
 
const devices = { device_1: "device_1" };
const tags   = { device: "device" };
const fields = {
  degrees: "degrees",
  sound: "sound_type",
  beats_per_inute: "beats_per_minute",
  sleep_stage: "sleep_stage",
  steps: "steps",
  minutes: "minutes",
  percentage: "percent",
};

Every stored point is an InfluxDB Point carrying three things: a measurement name that identifies the signal, a device tag that says where it came from, and a field with the actual value. Device as a tag is what allows multiple hardware nodes to share one platform.

Ingestion Path

Devices publish to per-measurement topics defined in back-end/mqtt/channels.js, e.g. measurement/temperature, measurement/heart, measurement/sleep.

The backend connects to the broker through subscriber.js, subscribes to the full topic list on connect, and becomes the recording endpoint:

client.on('message', (topic, message) => {
  if (topic === ch_temperature) {
    writeTemperature(message);
  } else if (topic === ch_heart) {
    writeHeartRate(message);
  }
  // ...
});

The write layer (db_write.js) converts each message into an InfluxDB point and tags it with the device, then defers the write and flushes in batches — points are queued and flushed on a schedule, which aggregates many small messages into efficient HTTP writes:

let point = new Point(measurements.temperature)
  .tag(tags.device, devices.device_1)
  .intField(fields.degrees, degrees);
 
setTimeout(() => writeClient.writePoint(point), 1000);
setTimeout(() => writeClient.flush(), 5000);

Storage, Bootstrapping, and Secrets

InfluxDB is provisioned on first boot by database/db_init.js. It calls the InfluxDB setup API to create the admin user, an organization, and a bucket (with retention), then issues a scoped API token used by the read, write, and delete clients.

Credentials are not hard-coded. The backend reads an InfluxDB API token from AWS Secrets Manager (secrets/aws_secrets.js), using EC2 instance-metadata credentials. On first run the secret does not exist, so the backend provisions InfluxDB, stores the new token in Secrets Manager, and continues — subsequent boots skip provisioning and read the stored token. This makes the deployment self-bootstrapping on a fresh cloud instance.

Read Path and REST API

The HTTP API (router/general.js) is a small Express router. Every endpoint accepts an optional time parameter so the frontend can ask for "everything since boot" (0), "since a date until now" (ISO string), or "the last N days" (Nd with a d suffix):

MethodRouteReturns
GET/api/allRaw time-series records
GET/api/temperature/:date?Temperature over the range
GET/api/heart/:date?Heart-rate samples
GET/api/sleep/:date?Sleep series + time-per-stage summary
GET/api/steps/:days?Daily step totals (windowed)
GET/api/sound, /api/action, /api/walking, /api/jogging, /api/biking, /api/idling, /api/oxygenCorresponding series

Queries are Flux, built per request:

from(bucket: "fitBucket")
  |> range(<start>[, stop: <now>])
  |> filter(fn: (r) => r._measurement == "steps" and r.device == "device_1")
  |> window(every: 1d)
  |> last()

Two patterns recur: simple range filters for continuous series (temperature, heart rate, sound) and window() + last() aggregation for counters like steps. The sleep endpoint additionally converts the raw stage stream into a time breakdown (db_read.js → getTimeSummary()), computing minutes and percentages spent in awake/light/rem/deep sleep.

CORS is enforced from an allow-list built from environment variables (FRONTEND_HOST, HOST_URL) so only the deployed dashboard origin can call the API.

Frontend Dashboard

The dashboard is a create-react-app build (front-end/) — React 18, react-bootstrap for layout, and Chart.js for visualizations. It is intentionally thin: there is no client-side data model of its own.

  • ApiContext.js fetches every API endpoint concurrently with Promise.all on mount, then folds the responses into a single dashboard state object (sleep stages, activity durations, heart-rate and oxygen series, step totals).
  • Chart components (HeartChart, SleepingLine, ActionChart, Bar, SleepSummary, Cards) subscribe to that context and render the data with Chart.js — a line chart overlaying heart rate and SpO₂, a stepped sleep-stage line, a doughnut and progress cards for activity, and a daily step bar chart.
  • In development the API base URL is REACT_APP_BACKEND_URL; in production the frontend calls relative /api/* paths that nginx forwards to the backend, so the browser never talks to different hosts directly.

Deployment

Docker Compose (single server)

docker-compose.yml wires all five services together with dependency ordering (frontend waits on backend, backend waits on influxdb and mosquitto) and persistent volumes for the database and broker. Provisioning scripts in the repo automate bringing a fresh server up: deploy_ubuntu.sh installs Docker and Compose on Ubuntu, and deploy.sh targets Amazon Linux.

Reverse proxy

nginx plays two roles:

  • HTTP — / serves the React build from the frontend container, /api/ proxies to the backend on :3000, and /mqtt/ upgrades to a WebSocket for browser MQTT clients connecting to Mosquitto on :9001.
  • TCP stream — a stream block proxies the outside world to Mosquitto's :1883, so devices on the internet can reach the broker at the server's public address.

AWS

Dockerrun.aws.json describes the same stack for AWS Elastic Beanstalk's multi-container Docker environment, mapping each service to persistent host volumes. Combined with Secrets Manager for the database token, this is the path the platform will use when it goes live at dopesilicon.com.

Design Notes

  • The onboarding was built first for a wearable activity tracker, so the current measurement set and dashboard layout are shaped by that first device. The transport, storage, and query layers are not device-specific — the hydroponics controller plugs into the same pipeline by publishing its own measurements.
  • Batching writes (queued points, scheduled flushes) trades a little latency for far fewer HTTP calls to InfluxDB, which matters when a device publishes multiple times per second.
  • Tagging every point with the device keeps multiple hardware nodes in one bucket and one API surface.
  • The backend subscribes to the full topic list at startup; a device's first published message is the only registration the platform needs.

Roadmap

  • Dynamic device and measurement registration so nodes self-describe on first connect
  • Configurable dashboards instead of the fixed chart layout
  • Broker authentication and TLS (MQTTS / WSS) for production device onboarding
  • Per-device and per-user access control
  • Retention policies per bucket and data lifecycle management
  • Integration with the hydroponics controller as the first non-wearable device