Eclipse Ditto Digital Twin: Hands-On Tutorial (2026)
Most digital-twin tutorials stop at theory. You read about a “virtual replica,” see a marketing diagram, and then face a blank terminal wondering where to start. This Eclipse Ditto digital twin tutorial takes a different approach: you will spin up a real Ditto stack in under ten minutes using Docker Compose, create a twin for an industrial robot, wire live MQTT telemetry to it, query the twin state via REST, and stream live updates to a browser client — all with commands you can copy and run today.
Eclipse Ditto is the Eclipse Foundation’s open-source framework for digital twins at scale. It abstracts devices as “Things” with structured properties and enforces fine-grained access policies — without requiring you to build that plumbing yourself. In 2026, with IEC 62443 and the EU Cyber Resilience Act pushing tighter device-identity and access-control requirements, Ditto’s policy model has become as important as its data model.
What this post covers: Ditto’s core data model, a working Docker Compose stack, Thing and Policy creation, MQTT Connectivity wiring, Things-Search queries, live WebSocket updates, and a summary of real production gotchas.
Why Eclipse Ditto Stands Apart in the Open Source Digital Twin Platform Landscape
Eclipse Ditto is not just another MQTT subscriber that writes to a database. It is a purpose-built digital twin microservice with a REST API that treats the twin — not the raw message stream — as the first-class citizen. Where most pipelines require you to assemble a broker, a time-series store, a schema registry, and a custom REST layer yourself, Ditto ships all of those concerns as a single composable platform with a stable, versioned API.
The key insight is the separation of concerns encoded in Ditto’s service split. The Things Service owns the authoritative twin snapshot. The Policies Service enforces who can read or write every sub-resource within a Thing. The Connectivity Service normalises incoming device messages — regardless of whether they arrive over MQTT, AMQP, Kafka, or HTTP — into a unified internal protocol before they ever touch the twin. And the Search Service provides eventually-consistent indexed queries so your application can ask “give me all robots with temperature > 80°C” without scanning every twin in MongoDB.
This architecture is documented in the Eclipse Ditto official documentation and aligns with the thing-description model in the W3C Web of Things (WoT) specification. Understanding types of digital twins helps frame where Ditto sits: it implements a synchronisation twin (real-time state shadow) with enough metadata and access control to evolve toward a simulation or behavioural twin later.

Figure 1: Eclipse Ditto’s five core services communicate over an Apache Pekko (formerly Akka) cluster. Every external request enters through the Gateway; device messages enter through the Connectivity Service.
Eclipse Ditto’s Core Data Model: Things, Features, and Policies
The data model is the foundation of the Eclipse Ditto things API. Get it wrong here and you will fight the framework for months.
The Thing: namespace, ID, attributes, and features
A Thing in Ditto is a JSON document with a stable identity and a predictable schema. Its full ID follows the pattern namespace:name, for example org.acme:robot-01. The Thing is composed of two layers:
- Attributes — static or slow-changing metadata about the device itself (model number, firmware version, installation site, serial number). These do not map to a specific capability; they describe the physical asset.
- Features — a named collection of logical capabilities, each with its own Properties (the actual telemetry and state values) and an optional Definition pointer (a WoT Thing Model or custom schema reference). A robot Thing might have features named
motor,gps,gripper, andsafety.
This feature/property split is intentional. It lets you grant a monitoring service read access to feature:/motor without exposing feature:/safety, and it lets your device SDK publish motor telemetry independently of GPS updates — you patch only the changed feature, and Ditto merges it into the twin atomically.

Figure 2: A Ditto Thing’s structure. The Policy is a separate top-level document referenced by ID; it governs every sub-resource inside the Thing.
The Policy: subject, resource, permission
Every Thing in Ditto is governed by a Policy. A Policy is its own REST resource (/api/2/policies/...) and can be shared across many Things — useful when you have a fleet of identical devices with the same access rules.
A Policy contains one or more entries, each of which combines:
– Subjects — authenticated principals (a user authenticated via Basic Auth or JWT, a device client-ID, a service account).
– Resources — the specific Thing sub-paths this entry controls (thing:/, policy:/, message:/, feature:/motor).
– Granted/Revoked permissions — READ and WRITE.
The evaluation is explicit: if no entry grants a subject permission on a resource, access is denied. There is no default-allow. This makes Ditto’s authorization model safe by default — an under-specified policy locks out rather than leaks.
Running Eclipse Ditto via Docker Compose
Before you can do anything, you need a running stack. The Ditto project ships an official Docker Compose deployment in its GitHub repository. The snippet below is a trimmed working starting point for local development.
# docker-compose.yml (illustrative — check the official repo for the current full version)
version: "3.8"
services:
mongodb:
image: mongo:6
restart: unless-stopped
volumes:
- ditto-mongo-data:/data/db
networks:
- ditto-net
policies:
image: eclipse/ditto-policies:latest
restart: unless-stopped
environment:
- MONGO_DB_URI=mongodb://mongodb:27017/policies
depends_on: [mongodb]
networks:
- ditto-net
things:
image: eclipse/ditto-things:latest
restart: unless-stopped
environment:
- MONGO_DB_URI=mongodb://mongodb:27017/things
depends_on: [mongodb]
networks:
- ditto-net
things-search:
image: eclipse/ditto-things-search:latest
restart: unless-stopped
environment:
- MONGO_DB_URI=mongodb://mongodb:27017/search
depends_on: [mongodb]
networks:
- ditto-net
connectivity:
image: eclipse/ditto-connectivity:latest
restart: unless-stopped
depends_on: [things, policies]
networks:
- ditto-net
gateway:
image: eclipse/ditto-gateway:latest
restart: unless-stopped
ports:
- "8080:8080"
environment:
- DEVOPS_PASSWORD=devops1234
depends_on: [things, policies, things-search, connectivity]
networks:
- ditto-net
volumes:
ditto-mongo-data:
networks:
ditto-net:
Start it with:
docker compose up -d
# Wait ~30 seconds for all services to initialise
curl -u ditto:ditto http://localhost:8080/api/2/things
# Should return 200 []
The Gateway exposes port 8080. The default credential for the pre-configured ditto user is ditto:ditto — change this in production by replacing the nginx auth configuration included in the full official compose file. The devops credential (devops:devops1234 in the example above) accesses administrative endpoints for Connectivity management.
Creating a Thing and Policy via the HTTP API
With the stack running, you create resources through the REST API. The Eclipse Ditto things API is fully idempotent — a PUT creates or replaces, a PATCH/merge-patch updates individual fields.
Step 1: Create the Policy
Create the policy first, because the Thing references its ID at creation time.
curl -X PUT \
-u ditto:ditto \
-H "Content-Type: application/json" \
-d '{
"entries": {
"owner": {
"subjects": {
"nginx:ditto": { "type": "nginx basic auth user" }
},
"resources": {
"thing:/": { "grant": ["READ","WRITE"], "revoke": [] },
"policy:/": { "grant": ["READ","WRITE"], "revoke": [] },
"message:/": { "grant": ["READ","WRITE"], "revoke": [] }
}
},
"device": {
"subjects": {
"nginx:robot-01-device": { "type": "device service account" }
},
"resources": {
"thing:/features/motor": { "grant": ["READ","WRITE"], "revoke": [] },
"thing:/features/gps": { "grant": ["READ","WRITE"], "revoke": [] }
}
}
}
}' \
http://localhost:8080/api/2/policies/org.acme:robot-01-policy
The owner entry grants the ditto admin user full access. The device entry grants robot-01-device write access only to the features it actually publishes — a least-privilege pattern. The HTTP response will be 201 Created with the policy document in the body.
Step 2: Create the Thing
curl -X PUT \
-u ditto:ditto \
-H "Content-Type: application/json" \
-d '{
"policyId": "org.acme:robot-01-policy",
"attributes": {
"model": "ACME-Arm-6DOF",
"firmwareVersion": "2.4.1",
"installationSite": "Plant-A-Line-3",
"serialNumber": "SN-20240815-001"
},
"features": {
"motor": {
"definition": ["org.acme:MotorModel:1.0.0"],
"properties": {
"status": "idle",
"rpm": 0,
"temperature": 21.5
}
},
"gps": {
"properties": {
"lat": 12.9716,
"lon": 77.5946,
"altitude": 920.0
}
}
}
}' \
http://localhost:8080/api/2/things/org.acme:robot-01
A 201 Created response confirms the twin is live. Retrieve it any time:
curl -u ditto:ditto \
http://localhost:8080/api/2/things/org.acme:robot-01
To update a single property without replacing the whole Thing, use the feature-property endpoint:
curl -X PUT \
-u ditto:ditto \
-H "Content-Type: application/json" \
-d '1480' \
http://localhost:8080/api/2/things/org.acme:robot-01/features/motor/properties/rpm
This is how your backend services keep the twin fresh when a device sends a targeted reading via an HTTP adapter.
Using merge-patch for partial updates
Ditto also supports RFC 7396 JSON Merge Patch at /api/2/things/{id} with Content-Type: application/merge-patch+json. A merge patch update sends only the fields you want to change; absent fields are left untouched and null values delete the key. This is valuable for bulk updates from a backend reconciliation job that reads the current state and writes only the diff:
curl -X PATCH \
-u ditto:ditto \
-H "Content-Type: application/merge-patch+json" \
-d '{
"attributes": { "firmwareVersion": "2.4.2" },
"features": {
"motor": {
"properties": { "status": "maintenance", "rpm": 0 }
}
}
}' \
http://localhost:8080/api/2/things/org.acme:robot-01
The merge-patch approach is safer than a full PUT when multiple services may be writing to different features of the same Thing concurrently, because each writer only touches its own sub-path rather than replacing the entire document.
Retrieving specific sub-paths
The Things API lets you GET any sub-path directly, which is useful for lightweight polling clients that only care about one value:
# Get a single feature
curl -u ditto:ditto \
http://localhost:8080/api/2/things/org.acme:robot-01/features/motor
# Get a single property
curl -u ditto:ditto \
http://localhost:8080/api/2/things/org.acme:robot-01/features/motor/properties/temperature
# Get only attributes
curl -u ditto:ditto \
http://localhost:8080/api/2/things/org.acme:robot-01/attributes
These sub-path reads hit the Things Service directly and are always consistent with the latest write — no search-index lag.
Wiring Ditto MQTT Connectivity to Ingest Device Telemetry
Updating the twin via REST from a backend is fine for simple integrations. For real devices publishing at field rates, the Ditto MQTT connectivity source is the right tool — it subscribes to an MQTT broker and maps incoming messages directly to twin commands without any intermediary code.
How the Connectivity Service maps MQTT messages
The Connectivity Service speaks the Ditto Protocol internally. When a device publishes a raw MQTT message, Ditto needs a payload mapping script (JavaScript or Nashorn) to convert that payload into a Ditto Protocol command. Ditto Protocol is a JSON envelope specifying which Thing and feature to modify.
A minimal Ditto Protocol modify command looks like:
{
"topic": "org.acme/robot-01/things/twin/commands/modify",
"headers": { "content-type": "application/json" },
"path": "/features/motor/properties",
"value": {
"status": "running",
"rpm": 1480,
"temperature": 74.2
}
}
If your device already publishes JSON in this exact format, no mapping script is needed. For devices publishing a simpler payload (e.g., {"rpm": 1480, "temp": 74.2}), you write a short JavaScript mapper.
Creating the MQTT Connectivity source
Use the devops API to create the connection. Replace mqtt://mosquitto:1883 with your broker’s address:
curl -X POST \
-u devops:devops1234 \
-H "Content-Type: application/json" \
-d '{
"name": "robot-mqtt-source",
"connectionType": "mqtt",
"connectionStatus": "open",
"uri": "mqtt://mosquitto:1883",
"sources": [
{
"addresses": ["ditto/commands/#"],
"consumerCount": 1,
"authorizationContext": ["nginx:ditto"],
"payloadMapping": ["javascript-mapper"]
}
],
"targets": [
{
"address": "ditto/responses/{{ thing:id }}",
"topics": ["_/_/things/twin/events/modified"],
"authorizationContext": ["nginx:ditto"]
}
],
"mappingDefinitions": {
"javascript-mapper": {
"mappingEngine": "JavaScript",
"options": {
"incomingScript": "function mapToDittoProtocolMsg(headers, textPayload, bytePayload, contentType) {\n var payload = JSON.parse(textPayload);\n return Ditto.buildDittoProtocolMsg(\n 'org.acme',\n 'robot-01',\n 'things',\n 'twin',\n 'commands',\n 'modify',\n '/features/motor/properties',\n headers,\n { rpm: payload.rpm, temperature: payload.temp }\n );\n}"
}
}
}
}' \
http://localhost:8080/devops/piggyback/connectivity
Once the connection is open, publish a test message from any MQTT client:
mosquitto_pub -h localhost -t "ditto/commands/motor" \
-m '{"rpm": 1520, "temp": 76.1}'
Poll the twin immediately after:
curl -u ditto:ditto \
http://localhost:8080/api/2/things/org.acme:robot-01/features/motor/properties
# Returns: {"status":"running","rpm":1520,"temperature":76.1}
The sequence from device publish to twin update is illustrated below.

Figure 3: The full MQTT-to-twin update sequence. The Connectivity Service handles protocol normalisation; the Things Service is the single writer to MongoDB.
Querying Twins with the Eclipse Ditto Things-Search API
The Things-Search API lets you query across your entire fleet using a structured filter language (RQL — Resource Query Language). It is backed by a separate index in MongoDB that Ditto populates asynchronously as things change, so queries are fast but eventually consistent (writes appear in the index within seconds under normal load).
Basic Things-Search examples
Retrieve all Things in the org.acme namespace:
curl -u ditto:ditto \
"http://localhost:8080/api/2/search/things?filter=eq(thingId,'org.acme:robot-01')"
Find all robots with motor temperature above 75°C:
curl -u ditto:ditto \
"http://localhost:8080/api/2/search/things?filter=gt(features/motor/properties/temperature,75)&fields=thingId,features/motor/properties/temperature,features/motor/properties/status"
The fields parameter projects only the sub-paths you need — critical for performance when Things are large. Pagination is via option=size(20),cursor(...). The cursor-based pagination is stateless and safe under concurrent updates.
Sort by temperature descending and return the top 10:
curl -u ditto:ditto \
"http://localhost:8080/api/2/search/things?\
filter=like(thingId,'org.acme*')\
&option=sort(-features/motor/properties/temperature),size(10)"
The RQL filter grammar supports eq, ne, gt, ge, lt, le, in, like, and, or, not — sufficient for most fleet-query workloads without custom code.
Live Updates: WebSocket and Server-Sent Events
Polling the REST API is fine for dashboards that refresh every few seconds. For truly live UIs — alarm panels, digital-twin visualisations, realtime PLM status boards — Ditto provides persistent push via WebSocket and Server-Sent Events (SSE).
Subscribing to twin events via SSE
SSE is the easiest path for browser clients because it works over a plain HTTP GET:
curl -u ditto:ditto \
-H "Accept: text/event-stream" \
"http://localhost:8080/api/2/things/org.acme:robot-01?fields=features/motor/properties"
Each time the motor feature changes, the client receives a JSON event pushed over the persistent connection without polling. For fleet-wide event streams, use the Things-Search SSE endpoint with a filter:
curl -u ditto:ditto \
-H "Accept: text/event-stream" \
"http://localhost:8080/api/2/search/things?filter=like(thingId,'org.acme*')&option=size(100)"
WebSocket subscription for bidirectional control
For applications that need to both receive events and send commands, the WebSocket API at ws://localhost:8080/api/2/ws/2 provides a single connection for both. After the handshake, you send a START-SEND-EVENTS command and Ditto begins forwarding matching twin-modified events as JSON frames.
// Browser WebSocket client (illustrative)
const ws = new WebSocket('ws://localhost:8080/api/2/ws/2', [], {
headers: { Authorization: 'Basic ' + btoa('ditto:ditto') }
});
ws.onopen = () => {
ws.send('START-SEND-EVENTS');
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.topic && msg.topic.includes('modified')) {
console.log('Twin updated:', msg.path, msg.value);
updateDashboard(msg);
}
};
For production React or Vue dashboards, wrap this in a useEffect hook with cleanup. The connection is long-lived; Ditto handles back-pressure by buffering events server-side for a configurable window before dropping slow subscribers.
Filtering event subscriptions for efficiency
Both the SSE and WebSocket APIs accept namespace and filter parameters so you only receive the events your application cares about. Adding a filter on the WebSocket connection significantly reduces the volume of events the Gateway must route:
// After connection open — request events for a specific namespace only
ws.send('START-SEND-EVENTS');
// Filter using the same RQL syntax as the Things-Search API
ws.send('START-SEND-MESSAGES:filter=like(thingId,"org.acme*")');
Without filtering, a gateway subscription to a busy Ditto cluster can receive tens of thousands of events per second. Always scope your subscriptions to the namespaces and Things your client actually renders.
Reacting to events for automated workflows
Beyond dashboards, the SSE endpoint is useful for lightweight rule-engine integrations. A Python worker can subscribe to the SSE stream and trigger downstream actions — sending an alert, updating a PLM record, or writing to an analytics pipeline — without polling:
import sseclient, requests
url = "http://localhost:8080/api/2/search/things"
params = {
"filter": "gt(features/motor/properties/temperature,80)",
"option": "size(100)"
}
headers = {
"Accept": "text/event-stream",
"Authorization": "Basic ZGl0dG86ZGl0dG8=" # ditto:ditto base64
}
response = requests.get(url, params=params, headers=headers, stream=True)
client = sseclient.SSEClient(response)
for event in client.events():
if event.data:
import json
msg = json.loads(event.data)
thing_id = msg.get("thingId")
temp = msg.get("features", {}).get("motor", {}).get(
"properties", {}).get("temperature")
print(f"ALERT: {thing_id} motor temperature {temp}°C — triggering maintenance workflow")
# call_maintenance_api(thing_id)
This pattern keeps application logic outside of Ditto itself and makes the rule-engine independently scalable.
The end-to-end dataflow from device to dashboard is shown in Figure 5.

Figure 5: The complete Eclipse Ditto integration. Actuation flows in reverse — the application sends a command through the Gateway and Connectivity Service back to the device via MQTT.
Integrating Eclipse Ditto with a UI or Analytics Layer
The Eclipse Ditto things API is designed to be a backend-for-frontend, not a final destination. Most production deployments place one or more consumers on top of it: a Grafana dashboard for operations teams, a custom React UI for field engineers, a PLM integration that pulls asset state into a product lifecycle record, or an analytics pipeline that samples twin state on a schedule.
Grafana with the Infinity plugin
Grafana’s Infinity datasource plugin can query the Ditto REST API directly. Configure a data source with the base URL http://localhost:8080/api/2 and Basic Auth credentials. Then build a panel with a query like:
URL: /search/things
Query params:
filter: like(thingId,"org.acme*")
fields: thingId,features/motor/properties/temperature,features/motor/properties/rpm,features/motor/properties/status
option: size(100)
The Infinity plugin parses the JSON array response and maps each thingId as a row. From there you can build table panels, stat cards, or threshold-based alert rules — all sourced directly from the twin’s current state, not a time-series database. This is the zero-infrastructure path: no InfluxDB, no Prometheus exporter required for a live operational view.
For historical trending, the pattern is to fan out: use the SSE endpoint (or a dedicated Connectivity target to Kafka) to write a copy of each twin-modified event to a time-series store. The digital twin holds the current snapshot; the time-series store holds the history. These are complementary, not competing.
Building a minimal React dashboard
For a custom UI, the simplest pattern is a React component that opens a WebSocket connection on mount, subscribes to twin events, and updates local component state. Here is the essential structure:
// TwinMonitor.jsx (illustrative — adapt auth for production)
import { useEffect, useState } from 'react';
export function TwinMonitor({ thingId }) {
const [twin, setTwin] = useState(null);
useEffect(() => {
// REST fetch for initial state
fetch(`/api/2/things/${thingId}`, {
headers: { Authorization: 'Basic ZGl0dG86ZGl0dG8=' }
})
.then(r => r.json())
.then(setTwin);
// WebSocket for live updates
const ws = new WebSocket('/api/2/ws/2');
ws.onopen = () => ws.send('START-SEND-EVENTS');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.thingId === thingId && msg.path) {
setTwin(prev => mergeUpdate(prev, msg.path, msg.value));
}
};
return () => ws.close();
}, [thingId]);
if (!twin) return <p>Loading twin…</p>;
const motor = twin.features?.motor?.properties;
return (
<div>
<h2>{thingId}</h2>
<p>Status: {motor?.status}</p>
<p>RPM: {motor?.rpm}</p>
<p>Temperature: {motor?.temperature}°C</p>
</div>
);
}
The mergeUpdate helper applies the Ditto event’s path and value to the local state copy, keeping it consistent with the server without a full re-fetch on every event. In practice this is a simple recursive path setter — write it once and reuse it across all twin consumers.
Policy and Authorization Model in Depth
The Policy model is the part most teams underestimate until they hit a production access-control incident. Figure 4 illustrates the evaluation chain.

Figure 4: Ditto’s policy evaluation. A request is authorised only if a matching entry grants the subject READ or WRITE on the exact resource path or a parent path.
Path-based resource hierarchy
Ditto uses prefix matching on resource paths. Granting thing:/ gives access to all sub-paths. Granting thing:/features/motor covers thing:/features/motor/properties/rpm but not thing:/features/gps. You can selectively revoke within a broader grant — for example, grant thing:/features, then revoke thing:/features/safety for an untrusted integration.
Subjects and JWT authentication
In production, replace the nginx: basic-auth subjects with JWT-backed subjects. Ditto’s Gateway accepts JWTs issued by any standard OIDC provider (Keycloak, Auth0, Azure AD). The subject becomes issuer:sub-claim. This means every device, service, and human user gets a unique identity and the Policy can be as granular as your operational model demands.
Policy import (since Ditto 3.x)
Policy imports let you build a base policy (say, org.acme:base-robot-policy) with common entries, and then import it into individual Thing policies for extension. This avoids duplicating the same 20-entry policy across a fleet of thousands. The import is resolved at access-check time, so changes to the base policy propagate immediately.
For a broader look at how namespace-based access control fits into broader unified namespace architectures, the access-control layer Ditto provides maps cleanly to the security boundary between the ISA-95 Operations layer and the Enterprise layer.
Trade-offs, Gotchas, and What Goes Wrong
Eclipse Ditto is production-grade software, but it has real operational limits that no marketing page will tell you. Here are the ones that actually bite teams.
Search index lag causes stale reads. The Things-Search index is updated asynchronously. In high-throughput environments — tens of thousands of events per second — the search index can lag by several seconds. If your automation logic depends on search results being current to the millisecond, use direct Things-Service reads (GET /api/2/things/{id}) instead. The Things Service reads directly from MongoDB and is always consistent.
MongoDB is the single persistence layer — and the bottleneck. Ditto does not support pluggable storage backends. Everything goes through MongoDB. At very large fleet scales (millions of Things), MongoDB sharding and index planning become critical. Under-indexed queries on feature properties can cause full collection scans that degrade the entire platform.
JavaScript payload mapping runs in a Nashorn sandbox. The in-process mapper is convenient but limited: no network I/O, no file access, single-threaded per connection. Complex transformations (binary decoding, crypto, external lookups) need a sidecar mapper service that speaks Ditto Protocol natively, not the built-in JavaScript engine.
Policy evaluation adds latency on every request. Every API call triggers a policy check via the Policies Service. In latency-sensitive paths — live updates at kilohertz rates — this adds measurable overhead. The mitigation is caching: Ditto caches policy decisions in memory, but cache misses during fleet-wide policy updates cause short spikes in request latency.
MQTT connectivity does not automatically reconnect indefinitely. If the MQTT broker restarts and the connection drops, Ditto’s Connectivity Service retries with exponential back-off, but messages published during the wind
