Skip to main content

Local Test Stack

The repository ships a docker-compose.yml at its root that spins up a complete set of protocol servers and simulators so you can develop and test OIBus connectors without access to real industrial equipment. This page documents every service, what it simulates, how to configure it, and how to bring it up.

Quick Start

The easiest way to start the stack is via the npm scripts defined in backend/package.json. Run them from the backend/ directory:

# IoT protocol servers only (OPC UA, Modbus, MQTT)
npm run docker:iot

# IoT servers + database servers + simulator (recommended for connector development)
npm run docker:simulator

# PostgreSQL + InfluxDB
npm run docker:database

# FTP / SFTP servers only
npm run docker:ftp

# Syslog test receiver only
npm run docker:logging

# Squid forward proxy (with auth) only
npm run docker:proxy

# Full development stack: IoT + simulator + database
npm run docker:dev

# Everything including OIBus runtime and nginx
npm run docker:all

# Tear down all containers
npm run docker:down

You can also invoke Docker Compose directly if you need a custom combination of profiles:

docker compose --profile iot --profile database --profile simulator up -d

Services are grouped into Docker Compose profiles:

ProfileServices
iotopcua-server, modbus-server, mqtt-broker
simulatorsimulator
databasepostgres, influxdb
ftpftp-server, sftp-server
loggingsyslog-server
proxysquid-proxy
oibusoibus, nginx
Profile independence

The simulator profile requires the iot and database profile services to be running (Modbus server, MQTT broker, InfluxDB and PostgreSQL). Always start them together: --profile iot --profile database --profile simulator (or use npm run docker:simulator, which does this automatically).

All services share the internal bridge network oibus-network. Ports are forwarded to localhost so OIBus running outside Docker (i.e. npm start in the backend/ directory) can reach them directly.


Services

OPC UA Server — opcua-server

PropertyValue
Imagemcr.microsoft.com/iotedge/opc-plc
Port50000 (OPC UA TCP)
Configdocker/opcua/nodes_config.json

Microsoft's OPC PLC simulator. It exposes a standard OPC UA server with custom nodes defined in nodes_config.json as well as a set of built-in nodes (boiler simulation, fast/slow changing variables, etc.).

Custom nodes (folder OIBus, all with Historizing: true):

Node IDDescriptionData typeSimulationParameters
1023Temperature (°C)DoubleRandom Walk18 – 28 °C, step 0.5, every 2 s
1024Pressure (hPa)DoubleSine Wave1013.25 ± 10 hPa, period 10 s
1025Flow rate (L/min)DoubleRandom Walk40 – 60 L/min, step 1, every 3 s
1026Humidity (%)DoubleSine Wave65 ± 15 %, period 15 s
1027RPMInt32Random Walk1 200 – 1 800, step 50, every 2.5 s
1028Pump statusBooleanSquare Waveperiod 20 s
1029Voltage (V)DoubleRandom Walk210 – 230 V, step 0.5, every 2 s
1030Current (A)DoubleSine Wave15.2 ± 2 A, period 12 s

Node IDs follow the OPC UA namespace ns=3;i=<NodeId>. The OPC UA address of temperature, for example, is ns=3;i=1023.

Historian support: Historizing: true enables OPC UA Historical Data Access (HA) on every custom node. The server answers HistoryRead requests, making it suitable to test OIBus history-query mode.

In-memory history only

History is stored in RAM — it is not persisted to disk. All historical data is lost when the container restarts. Scenarios that require catch-up after a long gap (days/weeks) cannot be reproduced with this simulator.

Authentication: anonymous access is disabled. Use the credentials configured via the environment variables OPCUA_DEFAULT_PASSWORD (default pass) and OPCUA_ADMIN_PASSWORD (default pass), with the usernames oibus and admin respectively (set in docker-compose.yml).

Connecting from OIBus: create a South OPC UA connector with the following settings:

SettingValue
URLopc.tcp://localhost:50000
Security modenone
Security policynone
Authenticationbasic
Usernameoibus
Passwordpass (or $OPCUA_DEFAULT_PASSWORD)

Modbus Server — modbus-server

PropertyValue
Imageoitc/modbus-server
Port5020 (Modbus TCP)
Configdocker/modbus/server_config.json

A lightweight Modbus TCP server. Its register map is declared in server_config.json. The server accepts writes from any Modbus TCP client, so the Simulator can dynamically update holding registers and coils in real time.

server_config.json key numbering vs OIBus Address offset

The configuration file uses 1-based register keys ("1", "2", …) because the server is configured with "zeroMode": false. This is a detail of the server's config file format only — Modbus TCP at the wire level is always 0-based, so the mapping is simply config key = protocol address + 1.

This is independent of the Address offset setting in OIBus (Modbus vs JBus). When connecting OIBus to this server, keep the default Modbus offset (no offset): OIBus sends 0-based protocol addresses, and the server resolves them against its 1-based keys internally. The JBus offset would only be needed for devices that expose 1-based addresses at the Modbus protocol level itself.

Initial register values (overwritten by the simulator after it connects):

Register typeProtocol addressInitial valueDescription
Input Register0314Firmware version (uint16)
Input Register122136Serial number — low word
Input Register24660Serial number — high word
Discrete Input0truePanel door closed
Discrete Input1trueSafety relay OK
Discrete Input2falseNetwork connected
Discrete Input3falseE-stop pressed

Input registers and discrete inputs are read-only from a Modbus client's perspective, so their values are static and come from server_config.json. Holding registers and coils are updated every 2 seconds by the simulator.

Connecting from OIBus: create a South Modbus connector with the following settings:

SettingValue
Hostlocalhost
Port5020
Slave ID1
Address offsetModbus

MQTT Broker — mqtt-broker

PropertyValue
Imageeclipse-mosquitto
Ports1883 (MQTT), 9001 (WebSocket)
Configdocker/mosquitto/config/

Eclipse Mosquitto with a custom entrypoint (docker/mosquitto/entrypoint.sh) that injects the MQTT_USER / MQTT_PASSWORD credentials at startup. Anonymous access is disabled.

The 9001 WebSocket port is available for browser-based MQTT clients if needed.

Connecting from OIBus: create a South MQTT connector with the following settings:

SettingValue
URLmqtt://localhost:1883
QoS1
Authenticationbasic
Usernameoibus
Passwordpass (or $MQTT_PASSWORD)

Items subscribe to the scalar topics published by the simulator (the JSON topics are meant for any-content / custom-transformer testing, not point-value items):


Syslog Server — syslog-server (profile: logging)

PropertyValue
Imagepython:3.14-slim
Ports514 (UDP), 514 (TCP)
Scriptdocker/syslog/syslog_server.py

A minimal syslog receiver used to test OIBus's Syslog logger (Engine settings → Logging → Syslog). It listens on both UDP and TCP on the same port and prints every received line to stdout — no parsing or authentication, it exists purely to let you see exactly what OIBus sends over the wire:

docker compose logs -f syslog-server

Connecting from OIBus: in Engine settings → Logging → Syslog, set Host localhost, Port 514, Protocol udp4 (or tcp), then enable the log level you want to test.


Squid Proxy — squid-proxy (profile: proxy)

PropertyValue
Imageubuntu/squid
Port3128 (HTTP/HTTPS forward proxy)
Configdocker/squid/conf.d/auth.conf, docker/squid/entrypoint.sh

A real Squid forward proxy requiring HTTP Basic authentication, used to test OIBus's proxy server — specifically the "Forward to upstream proxy" feature. docker/squid/entrypoint.sh generates /etc/squid/passwords at container startup from the SQUID_USER (default oibus) / SQUID_PASSWORD (default pass) environment variables using openssl passwd -6; docker/squid/conf.d/auth.conf is picked up by the default squid.conf's include /etc/squid/conf.d/*.conf and requires proxy_auth on every request.

To test the forward-proxy feature: enable OIBus's own proxy server, then enable Forward to upstream proxy with URL http://localhost:3128 and the credentials above. Requests through OIBus's proxy only succeed if it correctly attaches the upstream Proxy-Authorization header — watch docker compose logs -f squid-proxy, or try wrong credentials, to confirm.


PostgreSQL — postgres

PropertyValue
Imagepostgres
Port5432

A vanilla PostgreSQL instance for testing the South-PostgreSQL connector. Credentials are:

VariableDefault
POSTGRES_USERoibus
POSTGRES_PASSWORDpass
POSTGRES_DBoibus-db

Override passwords via the .env file or shell environment (e.g. POSTGRES_PASSWORD=secret docker compose up).

The Simulator writes rows into a sensor_readings table every POSTGRES_UPDATE_INTERVAL seconds (default 10 s), created automatically on first connect:

CREATE TABLE IF NOT EXISTS sensor_readings (
id SERIAL PRIMARY KEY,
"timestamp" TIMESTAMPTZ NOT NULL,
workshop TEXT NOT NULL,
sensor_id TEXT NOT NULL,
measurement TEXT NOT NULL,
value DOUBLE PRECISION NOT NULL
);

InfluxDB — influxdb

PropertyValue
Imageinfluxdb:2
Port8088 (host) → 8086 (container)

An InfluxDB 2.x instance for testing the South-InfluxDB connector, initialized on first start via the image's built-in setup mode. Credentials / connection details are:

VariableDefault
DOCKER_INFLUXDB_INIT_USERNAMEoibus
INFLUXDB_PASSWORDoibuspassword
DOCKER_INFLUXDB_INIT_ORGoibus
DOCKER_INFLUXDB_INIT_BUCKEToibus-bucket
INFLUXDB_TOKENoibus-admin-token
Why not pass?

Every other service in this stack defaults to the oibus / pass credentials, but InfluxDB 2 rejects passwords shorter than 8 characters during setup, so pass cannot be used here. Override it with your own value (8+ characters) via INFLUXDB_PASSWORD if needed.

The Simulator writes points to this bucket every INFLUXDB_UPDATE_INTERVAL seconds (default 10 s). Data is not persisted across container recreation (no volume is mounted), matching the postgres service's ephemeral setup.

The container listens on 8086 internally, mapped to host port 8088 to avoid clashing with a locally-installed InfluxDB. Other containers on oibus-network (like the simulator) reach it at http://influxdb:8086; from the host (e.g. OIBus running via npm start, or the InfluxDB UI in a browser) use http://localhost:8088.

Override the password/token via the .env file or shell environment (e.g. INFLUXDB_PASSWORD=secret docker compose up).

Example item query (version: 2, Flux): OIBus substitutes @StartTime / @EndTime directly (unquoted) into the item's query setting, so they can be used as Flux time literals in range(). This queries the temperature measurement written by the simulator, filtered down to one sensor via its tags:

South-InfluxDB item query
from(bucket: "oibus-bucket")
|> range(start: @StartTime, stop: @EndTime)
|> filter(fn: (r) => r._measurement == "temperature")
|> filter(fn: (r) => r._field == "value")
|> filter(fn: (r) => r.workshop == "workshop1" and r.sensor_id == "sensor1")
|> keep(columns: ["_time", "_value", "workshop", "sensor_id"])

Drop the last two filter() calls (or adjust the tag values) to pull every workshop/sensor for a measurement, or swap "temperature" for any of the other measurements the simulator writes (humidity, pressure, vibration, co2 — see the sensor table below).

Excluding the start of the range

Flux's range() is inclusive of start (start <= _time < stop). Since OIBus's next poll starts exactly where the previous one's @EndTime left off, a point landing exactly on that boundary would be returned twice. Add an explicit filter on _time to make the start exclusive too:

from(bucket: "oibus-bucket")
|> range(start: @StartTime, stop: @EndTime)
|> filter(fn: (r) => r._time > @StartTime)
|> filter(fn: (r) => r._measurement == "temperature")
|> filter(fn: (r) => r._field == "value")

FTP Server — ftp-server (profile: ftp)

PropertyValue
Imagefauria/vsftpd
Ports20, 21, 21100–21110 (passive)

Passive-mode vsftpd. Credentials: oibus / pass (override the password via FTP_PASSWORD). Files land in docker/ftp/data/.


SFTP Server — sftp-server (profile: ftp)

PropertyValue
Imageatmoz/sftp
Port2222 (SSH)

Single-user SFTP server. Credentials: oibus / pass (override the password via SFTP_PASSWORD). Upload directory: docker/sftp/data/.


OIBus Runtime — oibus (profile: oibus)

PropertyValue
Imageghcr.io/optimistiksas/oibus
Port2223 (web UI / API)
Data./data-folder/app/OIBus/OIBusData

The OIBus runtime itself, useful when you want to test the full stack inside Docker rather than running the backend with npm start. See Docker Image for details about this image.


Nginx — nginx (profile: oibus)

PropertyValue
Imagenginx
Ports80 (HTTP), 443 (HTTPS)
Configdocker/nginx/

Reverse proxy in front of the OIBus container. Requires the DOMAIN environment variable and TLS certificates in docker/nginx/certs/. Only needed when testing the full TLS / reverse-proxy setup.


Unified Simulator — simulator

PropertyValue
Imagepython:3.14-slim
Scriptdocker/simulator/simulator.py
Librariespymodbus==3.6.9, paho-mqtt, influxdb-client, psycopg2-binary

A single Python script that drives the Modbus server, the MQTT broker, InfluxDB and PostgreSQL. It runs one daemon thread per source, each with its own independent retry loop so a failure in one source does not affect the others.

Modbus thread

Writes to the Modbus server every MODBUS_UPDATE_INTERVAL seconds (default 2 s). All values are sinusoidal with 5 % random noise unless stated otherwise.

Holding registers — uint16 (1 word):

Protocol addrNameBaseAmplitudePeriod
0temperature2505060 s
1humidity600200120 s
2pressure10030180 s
3vibration25020030 s
4co2600200300 s
5flow_rate1508090 s

Holding registers — extended data types (multi-word):

Protocol addrNameData typeBaseAmplitudePeriod
6outdoor_tempint16525240 s
7 – 8production_countuint3250 00040 000600 s
9 – 10power_kwfloat75.545.0180 s
11 – 12energy_balanceint3205 000360 s
13 – 16shaft_speeddouble1 500.0300.0120 s
17status_flagsbitfield

status_flags is a 16-bit register whose individual bits are independent square waves:

BitNamePeriod
0motor_running60 s
1fault_detected300 s
2maintenance_due600 s
3overload120 s

Coils (square wave, 1 = on for first half of period):

Protocol addrNamePeriod
0pump_running30 s
1valve_open45 s
2alarm_active120 s
3machine_on20 s
Multi-word encoding

OIBus applies an unconditional swap32() + swap16() on multi-word values before reading them. The simulator accounts for this by writing the low 16-bit word before the high 16-bit word within each 32-bit dword. This matches the default OIBus settings (swapWordsInDWords: false, endianness: big-endian).

MQTT thread

Publishes to the MQTT broker every MQTT_UPDATE_INTERVAL seconds (default 2 s). Each publish cycle sends two families of topics:

  • Scalar topics — a single number, for value-based items.
  • JSON topics (under <workshop>/json/<shape>) — structured payloads of different shapes. OIBus's MQTT south ingests these as any-content, which makes them ideal for exercising custom transformers.
Scalar topics

Topics follow the pattern <workshop>/<sensor>/<type> and carry a bare number (e.g. 23.5). All values are sinusoidal with 5 % random noise.

TopicBaseAmplitudePeriod
workshop1/sensor1/temperature30.010.060 s
workshop1/sensor2/humidity55.025.0120 s
workshop1/sensor3/pressure1 000.050.0180 s
workshop1/sensor4/vibration5.05.030 s
workshop2/sensor1/temperature28.08.090 s
workshop2/sensor2/humidity50.020.0150 s
workshop2/sensor3/pressure990.040.0210 s
workshop2/sensor4/vibration4.04.045 s
JSON topics

Each topic publishes a different JSON shape, so connectors and custom transformers can be tested against the full range of payloads OIBus may receive over MQTT. Numeric values vary every cycle (sinusoidal with noise).

TopicShape
workshop1/json/flatFlat object (single reading)
workshop1/json/nestedNested objects
workshop1/json/arrayArray of readings (a batch)
workshop2/json/mixedEvery JSON scalar type + array + object
workshop2/json/stringA JSON string
workshop2/json/numberA bare JSON number

Example payloads:

workshop1/json/flat
{ "value": 35.17, "unit": "celsius", "timestamp": "2026-06-04T08:15:06.673+00:00", "quality": "good" }
workshop1/json/nested
{
"sensor": { "id": "sensor-42", "type": "temperature", "location": { "workshop": "workshop1", "line": 3 } },
"reading": { "value": 34.9, "timestamp": "2026-06-04T08:15:06.673+00:00" }
}
workshop1/json/array
[
{ "timestamp": "2026-06-04T08:15:06.673+00:00", "value": 34.95 },
{ "timestamp": "2026-06-04T08:15:08.673+00:00", "value": 35.48 },
{ "timestamp": "2026-06-04T08:15:10.673+00:00", "value": 37.16 }
]
workshop2/json/mixed
{
"int": 116,
"float": 3.741,
"bool": true,
"string": "ok",
"null": null,
"tags": ["alpha", "beta"],
"nested": { "a": 1, "b": [1, 2, 3] }
}
workshop2/json/string
"reading-12"
workshop2/json/number
42.7
Testing custom transformers

The nested, array and mixed payloads contain fields that are themselves objects or arrays. They are useful to test a custom transformer that derives the output filename or content from a payload field — returning a non-string value there must never reach the metrics database, and these topics make that edge case easy to reproduce.

InfluxDB thread

Writes to InfluxDB every INFLUXDB_UPDATE_INTERVAL seconds (default 10 s). Each write cycle writes one point per sensor, each tagged with workshop and sensor_id and carrying a single value field, spread across several measurements so OIBus's InfluxDB south connector has multiple measurements/tags to query against:

MeasurementWorkshopSensor idBaseAmplitudePeriod
temperatureworkshop1sensor122.05.060 s
humidityworkshop1sensor245.015.090 s
pressureworkshop1sensor31010.020.0120 s
temperatureworkshop2sensor120.04.075 s
vibrationworkshop2sensor23.02.040 s
co2workshop2sensor3500.0150.0200 s

PostgreSQL thread

Writes to PostgreSQL every POSTGRES_UPDATE_INTERVAL seconds (default 10 s), inserting one row per sensor into the sensor_readings table (see PostgreSQL above). It uses the exact same sensor list as the InfluxDB thread, with workshop/sensor_id/measurement columns standing in for InfluxDB's tags — so both databases end up with the same data, shaped for their respective query models (Flux/InfluxQL tags vs a SQL WHERE clause).

Environment variables

VariableDefaultDescription
RETRY_INTERVAL10Seconds between reconnection attempts
MODBUS_HOSTmodbus-serverHostname of the Modbus server
MODBUS_PORT5020Modbus TCP port
MODBUS_SLAVE_ID1Modbus slave / unit ID
MODBUS_UPDATE_INTERVAL2Seconds between Modbus write cycles
MQTT_BROKERmqtt-brokerHostname of the MQTT broker
MQTT_PORT1883MQTT port
MQTT_USERoibusMQTT username
MQTT_PASSWORDpassMQTT password (also set via $MQTT_PASSWORD)
MQTT_UPDATE_INTERVAL2Seconds between MQTT publish cycles
INFLUXDB_URLhttp://influxdb:8086InfluxDB base URL
INFLUXDB_TOKENoibus-admin-tokenInfluxDB API token (also set via $INFLUXDB_TOKEN)
INFLUXDB_ORGoibusInfluxDB organisation
INFLUXDB_BUCKEToibus-bucketInfluxDB bucket
INFLUXDB_UPDATE_INTERVAL10Seconds between InfluxDB write cycles
POSTGRES_HOSTpostgresHostname of the PostgreSQL server
POSTGRES_PORT5432PostgreSQL port
POSTGRES_USERoibusPostgreSQL username
POSTGRES_PASSWORDpassPostgreSQL password (also set via $POSTGRES_PASSWORD)
POSTGRES_DBoibus-dbPostgreSQL database name
POSTGRES_UPDATE_INTERVAL10Seconds between PostgreSQL write cycles

Passwords and Secrets

Every service uses the same default credentials, oibus / pass, so there's a single pair to remember when connecting OIBus to any of them. The one exception is InfluxDB, whose password must be at least 8 characters (see its note above). Sensitive values are read from environment variables — create a .env file at the repository root to override them locally without touching docker-compose.yml:

.env
MQTT_PASSWORD=my_mqtt_secret
POSTGRES_PASSWORD=my_pg_secret
INFLUXDB_PASSWORD=my_influx_secret
INFLUXDB_TOKEN=my_influx_token
FTP_PASSWORD=my_ftp_secret
SFTP_PASSWORD=my_sftp_secret
OPCUA_DEFAULT_PASSWORD=my_opcua_secret
OPCUA_ADMIN_PASSWORD=my_admin_secret
SQUID_PASSWORD=my_squid_secret
DOMAIN=oibus.example.com

.env is listed in .gitignore — it will never be committed.


Useful Commands

# Start the recommended development stack (IoT servers + simulator + database)
npm run docker:dev

# Tail simulator logs (Modbus + MQTT writes)
docker compose logs -f simulator

# Restart the simulator after changing docker/simulator/simulator.py
docker compose --profile simulator up -d --force-recreate simulator

# Restart the Modbus server after changing docker/modbus/server_config.json
docker compose --profile iot up -d --force-recreate modbus-server

# Stop everything and remove containers (data volumes are kept)
npm run docker:down