Skip to content

Self-hosted with Docker

The repository includes a complete Docker stack: MongoDB, Mosquitto, Azurite (blob storage for catalog media), the REST API, the ingest service and the web application. The host only needs Docker; every image is built from the repository.

Requirements

  • A Linux VM or server (for example Ubuntu 22.04 / 24.04) with 2 vCPU, 4 GB RAM and 20 GB of disk.
  • Docker Engine with the Compose v2 plugin (docker compose version), git, openssl and curl.
  • Internet access while building the images.

Install

Terminal window
git clone <repository-url> linter-iot && cd linter-iot
./scripts/stack.sh init # infra/.env with random secrets + local MQTT certificates
nano infra/.env # set STACK_HOST to the IPv4 address or DNS name users will open
./scripts/stack.sh up # build and start (the first time takes several minutes)
./scripts/stack.sh seed # demo data and users (deletes the current data)
./scripts/stack.sh smoke # health checks + MQTT → ingest → Mongo round trip

Open http://<STACK_HOST>:<WEB_PORT> (default port 8080) and sign in with a demo user, for example root@linter.test / demo1234. Change the demo passwords, or replace the demo users, before giving anyone else access.

Day to day: ./scripts/stack.sh ps, logs [service], down (keeps the data; down -v deletes it) and help. To update: git pull && ./scripts/stack.sh up.

ServiceDefault portNotes
Web8080SPA on nginx; the API URL is written at start (/config.js)
API3010REST API, Swagger at /docs
Ingest8000MQTT/HTTP ingest and command dispatcher, /health
Mosquitto8883MQTT over TLS, client certificate required
MongoDB27017
Mongo Express8081Database browser (login in infra/.env)
Azurite10000Blob storage for catalog media

With the infra/.env made by init, the ingest, MongoDB and Mongo Express listen on 127.0.0.1 only (ADMIN_BIND, see Ports and firewall).

Configuration (infra/.env)

init copies infra/.env.example and fills the empty secrets (JWT_SECRET, MONGO_INITDB_ROOT_PASSWORD, MONGO_EXPRESS_PASSWORD). Apply any change with ./scripts/stack.sh up.

SettingMeaning
STACK_HOSTAddress browsers and devices use: an IPv4 address or a DNS name (for IPv6, use a DNS name). It goes into the web → API URL (http://STACK_HOST:API_PORT), the allowed web origin, the media links and the broker certificate (up reissues it when the host changes and restarts Mosquitto). The API also uploads media there, see below.
MQTT_EXTRA_HOSTSMore names or IPs devices use to reach the broker (comma separated), added to the broker certificate.
WEB_PORT, API_PORT, INGESTA_PORT, MQTT_PORT, MONGO_PORT, MONGO_EXPRESS_PORT, AZURITE_PORTPorts published on the host.
ADMIN_BINDAddress where the Mongo, Mongo Express and ingest ports are published (127.0.0.1 in the example file; empty = every interface).
PUBLIC_API_URL, CORS_ORIGINBehind a reverse proxy. Defaults: http://STACK_HOST:API_PORT and http://STACK_HOST:WEB_PORT. The first CORS_ORIGIN is the web URL used in emails and label QR codes.
PUBLIC_DOCS_URLDeveloper docs link in the web. Empty = the public docs site (the docs app is not part of the stack).
AZURE_STORAGE_CONNECTION_STRING, MEDIA_CONTAINERReal storage account (or another blob endpoint) instead of the stack’s Azurite.
MOSQUITTO_CONFBroker config in infra/mosquitto/config/: stack.conf (default) enables the topic ACL described below.
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROMEmail notifications. Without them, notifications stay queued.

CORS_ORIGIN must match the browser’s address bar exactly. When it is not set, stack.sh builds it from STACK_HOST in lowercase and leaves out :80; with HTTPS on 443 behind a proxy, set it to the URL without the port.

The API container uploads catalog media to STACK_HOST:AZURITE_PORT, the same address it stores for browsers. A DNS name is mapped to the machine inside the container; an IP address is used as is, so it must be one the machine reaches itself, that is, an address on its own network interface. On a cloud VM whose public IP is NATed outside the VM, use a DNS name, or set AZURE_STORAGE_CONNECTION_STRING.

Two MQTT ports

  • MQTT_PORT is the port where the stack’s own Mosquitto is published on the host, the one devices connect to.
  • MQTT_HOST (empty = the stack’s Mosquitto) and MQTT_BROKER_PORT (default 8883) are the broker the ingest and the simulator connect to.

Publishing the local broker on another port does not change how the ingest reaches it inside the Docker network.

Simulated devices

Terminal window
./scripts/stack.sh simulator # start in the background
./scripts/stack.sh logs simulator # telemetry, received commands and acks
./scripts/stack.sh simulator stop

The simulator publishes telemetry every 10 s for the MACs of the bundled samples (a4cf12bb73d2 to a4cf12bb73d5) using real payloads with a small variation, listens on commands/<mac> and answers on commands/<mac>/ack. SIM_DEVICES (partNumber:mac,...), SIM_INTERVAL and SIM_ACK (ok, error or none) change its behaviour.

The seed loads telemetry for those MACs but does not register them as devices. To see a command confirmed:

  1. Sign in as root@linter.test (the seed gives it the permission to send commands).
  2. In Labels, choose the subsidiary Linter Technical Center, the model EJET and the MAC a4cf12bb73d2, and download the label with Register the device on the platform when downloading ticked.
  3. With the simulator running, open the device’s Commands tab and send a Free packet (the demo catalog has no templates) with the {{commandId}} placeholder in it, for example {"commandId": "{{commandId}}", "action": "ping"}. It moves from Sent to Confirmed within a few seconds; with SIM_ACK=error it ends as Rejected by the device, and with SIM_ACK=none it stays Sent.

Device commands describes the protocol the simulator implements.

Real devices against the stack broker

init creates a test CA and certificates in infra/mosquitto/certs/local/. A device needs ca.crt plus its own certificate and key and connects to mqtts://<STACK_HOST>:<MQTT_PORT>. Issue one certificate per device with its MAC as the name, written as in its topics (lowercase hex, no separators): infra/mosquitto/local-certs.sh --client a4cf12bb73d2. The script only writes into certs/local/, keeps the certificates that are still valid and leaves any other file there alone; the CA key stays on the machine.

The stack’s broker (stack.conf) applies the topic ACL in infra/mosquitto/config/acl. The certificate CN is the MQTT username:

  • A device may publish telemetry/<partNumber>/<its CN> and commands/<its CN>/ack, and read commands/<its CN>. Anything else is dropped and logged as Denied PUBLISH.
  • Only the ingest (linter-ingesta) publishes commands.
  • The sample linter-device-local certificate and the simulator’s may speak for any device, but cannot send commands. Keep them for tests.
  • Packets over 256 KB disconnect the client.

Using your own MQTT broker

Terminal window
MQTT_HOST=mqtt.example.com
MQTT_BROKER_PORT=8883
MQTT_TLS=true
MQTT_USERNAME=linter-ingesta # brokers with username/password
MQTT_PASSWORD=...
# Brokers that require a client certificate: copy the files into
# infra/mosquitto/certs/local/ (mounted at /certs) and point to them.
MQTT_CA_CERT=/certs/broker-ca.crt
MQTT_CLIENT_CERT=/certs/ingesta.crt
MQTT_CLIENT_KEY=/certs/ingesta.key
  • local-certs.sh (run by up) only touches the certificates it manages: files copied there keep their names and permissions.
  • A broker certificate from a public CA: MQTT_CA_CERT= (empty) uses the system CAs. Without TLS: MQTT_TLS=false (usually port 1883).
  • The ingest subscribes to telemetry/+/+ and commands/+/ack and publishes to commands/<deviceId> (QoS 1); its broker account needs those rights.
  • MQTT_CLIENT_ID must be unique on that broker: two ingests with the same id keep disconnecting each other.
  • The simulator uses the same broker with its own credentials (SIM_MQTT_USERNAME, SIM_MQTT_PASSWORD, SIM_CA_CERT, SIM_CLIENT_CERT, SIM_CLIENT_KEY).
  • smoke then checks that the ingest has an open session with that broker and that the command dispatcher is running.

Ports and firewall

Users and devices need WEB_PORT, API_PORT (the browser calls the API directly), MQTT_PORT and AZURITE_PORT (catalog videos). Azurite uses the well-known development key, so anyone who reaches AZURITE_PORT can also change the media: keep it on the internal network or use a real storage account.

MONGO_PORT, MONGO_EXPRESS_PORT and INGESTA_PORT (its HTTP telemetry endpoint has no authentication) are published only on ADMIN_BIND, which is 127.0.0.1 in infra/.env.example. From another computer, use an SSH tunnel, for example ssh -L 8081:127.0.0.1:8081 user@<STACK_HOST> and then http://localhost:8081. ADMIN_BIND= (empty) publishes them on every interface, which devices that send telemetry over HTTP need; then limit them in the firewall to administrators and those devices.

Docker publishes ports through its own iptables rules, which ufw does not filter: restrict them in the network or cloud firewall.

Troubleshooting

  • The web loads but login fails (“Failed to fetch”): the browser cannot reach the API at the URL in http://<STACK_HOST>:<WEB_PORT>/config.js, or the API rejects the web origin (CORS_ORIGIN must match the address bar). Fix STACK_HOST, PUBLIC_API_URL or CORS_ORIGIN and run up again.

  • A container keeps restarting: ./scripts/stack.sh logs api (or ingesta, web…). For the API it is usually JWT_SECRET or the Mongo credentials.

  • “Authentication failed” after changing MONGO_INITDB_ROOT_PASSWORD: the password is only applied when the data volume is created. Restore the old value, or start over with ./scripts/stack.sh down -v (deletes all data).

  • Devices cannot connect to the broker: their certificate must be signed by infra/mosquitto/certs/local/ca.crt, and the host they use must be in the broker certificate: add it to MQTT_EXTRA_HOSTS (or fix STACK_HOST) and run ./scripts/stack.sh up.

  • A device connects but its telemetry never arrives: ./scripts/stack.sh logs mosquitto shows Denied PUBLISH. Its certificate CN must be the MAC in its topics (see Real devices).

  • Port already in use: change the matching *_PORT in infra/.env.

  • The demo catalog videos do not play: the seed gives the EJET and UTANK models video links that point to 127.0.0.1:10000, and those files are not in the repository, so they fail everywhere. Upload your own videos (or remove them) in Device types › Animation.

  • Uploaded videos only play on the machine itself, or uploading fails: media links use STACK_HOST; with localhost other computers look for them on their own machine. Use a DNS name, or an IP on the machine’s own interface (see Configuration).

  • Media blocked behind an HTTPS proxy: the default media links are http://. Set AZURE_STORAGE_CONNECTION_STRING to an HTTPS blob endpoint.

  • On a machine that already uses pnpm infra:up: pnpm infra:up reads infra/.env too, so a stack configured there also changes the development containers (names, ports, certificates, Mongo credentials). Leave infra/.env absent and give the stack its own env file:

    Terminal window
    export STACK_ENV_FILE=infra/.env.stack.local # gitignored
    ./scripts/stack.sh init
    # in that file: COMPOSE_PROJECT_NAME=linter-stack, CONTAINER_PREFIX=linter-stack
    # and ports other than 3010, 8000, 8883, 27017, 8081 and 10000
    ./scripts/stack.sh up

    Every stack.sh command needs the same STACK_ENV_FILE.