Eclipse Zenoh 1.9.0 on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and use of Eclipse Zenoh 1.9.0 on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images.
Zenoh (/zeno/) is a protocol that unifies data in motion, data at rest and computations. It blends traditional publish/subscribe with distributed queries and geo-distributed storage, at a level of time and space efficiency well beyond the mainstream stacks — which is why it has become a common backbone for robotics, vehicles and industrial edge estates.
This image ships zenohd, the Zenoh router. That is the infrastructure piece: the node your peers and clients connect to, that routes their data, answers their queries, and holds the storage they read back later. It is not a library and not a demo; it is the thing you deploy once and point everything else at.
What a router gives you
| Capability | What it means in practice |
|---|---|
| Publish / subscribe | A process anywhere in your estate publishes on a key expression like cloudimg/app/site-a/sensor/42, and every subscriber whose expression matches receives it. Wildcards (*, **) make the routing declarative rather than something you wire up by hand. |
| Query (get / queryable) | A process declares itself queryable on a key expression and answers requests on demand. This is request/response over the same fabric, with the same addressing, so you do not need a second stack for it. |
| Storage | The router persists whatever is written under a chosen key expression, and answers later queries for it from disk. Data at rest and data in motion share one namespace. |
| Federation | Routers connect to each other to span sites, so a subscriber in one region transparently receives from a publisher in another. |
The shape of the deployment
| Port | Protocol | What it is |
|---|---|---|
7447 |
TCP | The Zenoh router. Every peer and client session lands here. Authentication is mandatory. |
8000 |
TCP | The REST plugin, an HTTP view of the same key space. Bound to 127.0.0.1 only — see below for why that matters more than it looks. |
22 |
TCP | SSH. |
Key expressions this image sets up for you out of the box:
cloudimg/app/** the application key space, usable by both shipped identities
cloudimg/store/** persisted to disk, readable again after a restart
Security by design — and why this image replaces upstream's configuration
The configuration that ships inside the zenohd package listens on tcp/[::]:7447 with no authentication and no access control, and leaves multicast discovery switched on. Anyone who can reach the port can publish to, and subscribe to, any key expression. That is a perfectly reasonable default for a laptop experiment and a completely unacceptable one for an appliance with a public IP, so this image replaces that file wholesale:
-
Every session must authenticate. Zenoh's user/password exchange is an HMAC challenge/response over a nonce the router issues, so the password itself never crosses the wire. A client with no credentials is refused before the open handshake even begins; a client with the wrong password is refused at it.
-
The credentials are generated on your VM, on its first boot. Two 32-character secrets, from
openssl, on your machine. Nothing is baked into the image, so no two customers ever share a Zenoh login. The router physically cannot start until first boot has written them. -
Access control is on, and defaults to deny. Two identities ship:
cloudimgis the administrator and may use any key expression, andcloudimg-appis a least-privilege application identity confined tocloudimg/app/**. A publish fromcloudimg-appoutside that subtree is dropped by the router. -
Multicast discovery is off. Upstream enables it, which makes the router announce itself to — and auto-connect with — anything that shouts on the local multicast group. This guide shows you the three lines to change if you want it back inside a network you control.
-
The REST plugin is bound to loopback, and this is load-bearing rather than tidiness. Zenoh's access-control interceptor is installed on transport faces only. A plugin running inside the router holds a local face and is therefore not subject to the access-control rules at all. A REST plugin listening on
0.0.0.0would be an unauthenticated door straight past every rule above. It is bound to127.0.0.1, which is a kernel-level guarantee, and you reach it over an SSH tunnel. -
The admin space is off, so the REST plugin cannot be used to read or rewrite the router's live configuration.
-
The router refuses to start on a published key or credential. Eclipse Zenoh's own test suite contains real private keys and real user/password pairs (
routername:routerpasswdand friends). This image scans the upstream source tree for the exact packaged tag, builds a deny-list, and runs a guard before the router opens a socket — so an accidental copy/paste of an upstream example into your dictionary stops the router rather than exposing your bus.
A note on the licence
Eclipse Zenoh is dual licensed: the LICENSE file carries the full text of both the Apache License 2.0 and the Eclipse Public License 2.0, and the recipient elects one. cloudimg elects Apache-2.0 for this image, and this guide and the Marketplace listing say so. The project also offers EPL-2.0 and you may elect that instead if it suits your organisation better. (GitHub's licence API reports NOASSERTION for this repository, which is a classification quirk of dual-licence headers — the file itself is unambiguous.)
What is included:
-
Eclipse Zenoh
zenohd1.9.0 from the upstream Eclipse Zenoh Debian packages, with the REST plugin and the storage manager plugin -
The Eclipse Zenoh filesystem storage backend 1.9.0, so
cloudimg/store/**is persisted to disk and survives a restart -
The official
eclipse-zenohPython API at the matching 1.9.0, in its own virtualenv, so you have a working client on the box from the first minute -
Per-VM credentials generated on first boot, access control defaulting to deny, and a committed-secret guard
-
Log rotation and retention for the router's own log
Before you start
You will need an Azure subscription, permission to create a virtual machine, and an SSH key pair. Nothing else — the image is self-contained.
Decide up front which network is allowed to reach port 7447. Zenoh has no concept of "read only" at the network layer; anything that authenticates can use the key expressions its identity permits. Treat the port like a database port, not like a web port.
Deploy the virtual machine
Deploy from the Azure Marketplace listing, or from the CLI. Standard_B2s (2 vCPU, 4 GiB) is the recommended size and has a great deal of headroom: the router was measured peaking at 28 MB of resident memory while receiving 4,000 messages.
az group create --name zenoh-rg --location eastus
az vm create \
--resource-group zenoh-rg \
--name zenoh-router \
--image cloudimg:zenoh-ubuntu-24-04:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
Network Security Group rules
Open SSH and the router port. Replace <your-network> with your own CIDR — the example below is deliberately not 0.0.0.0/0.
az network nsg rule create \
--resource-group zenoh-rg --nsg-name zenoh-routerNSG \
--name allow-zenoh --priority 1010 \
--source-address-prefixes '<your-mgmt-cidr>' \
--destination-port-ranges 7447 --protocol Tcp --access Allow
Do not open 8000. The REST plugin is bound to loopback and is reached over an SSH tunnel; an NSG rule for it would do nothing except advertise a port that is not listening.
Connect and read this VM's credentials
First boot generates this VM's credentials and writes them to a root-only file.
ssh azureuser@<vm-ip>
sudo cat /root/zenoh-credentials.txt
You will see the two identities, their passwords, the endpoint to connect to, and the paths that matter:
ZENOH_ROUTER_ENDPOINT=tcp/20.172.141.80:7447
ZENOH_ADMIN_USER=cloudimg
ZENOH_ADMIN_PASSWORD=<32 hex characters, generated on this VM>
ZENOH_APP_USER=cloudimg-app
ZENOH_APP_PASSWORD=<32 hex characters, generated on this VM>
ZENOH_APP_KEY_PREFIX=cloudimg/app
ZENOH_STORAGE_KEY_PREFIX=cloudimg/store
ZENOH_CLIENT_PYTHON=/usr/local/lib/cloudimg/zenoh-venv/bin/python
Keep the administrator password for operations and hand cloudimg-app to your applications. That is the whole point of shipping two: an application that only ever needs cloudimg/app/** should not hold a credential that can read everything.
Confirm the router is running
sudo systemctl is-active zenohd.service
/usr/bin/zenohd --version
ss -ltn | grep -E ':(7447|8000)'
The router listens on 0.0.0.0:7447, and the REST plugin on 127.0.0.1:8000 and nowhere else.

Everything in that "effective security posture" block is read back through Zenoh's own configuration parser rather than by grepping the file, so a setting that is present but overridden, misnested or commented out cannot look correct.
Your first publish and subscribe
The image carries the official Eclipse Zenoh Python API, so you have a working client immediately. A small helper, zenoh_probe.py, wraps it — every subcommand opens its own session, so a publisher and a subscriber really are two independent clients talking through the router.
This one block starts a subscriber, waits until it is genuinely declared, then publishes from a separate process and shows what arrived. Paste it whole:
ZP=/usr/local/lib/cloudimg/zenoh-venv/bin/python
PROBE=/usr/local/lib/cloudimg/zenoh_probe.py
AP=$(sudo awk -F= '/^ZENOH_ADMIN_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
# a subscriber process, waiting for one sample
sudo $ZP $PROBE subscribe --user cloudimg --password "$AP" \
--key 'cloudimg/app/demo/**' --count 1 --duration 25 > /tmp/zenoh-sub.out 2>&1 &
until grep -q SUBSCRIBER_READY /tmp/zenoh-sub.out; do sleep 0.2; done
# ... and a SEPARATE publisher process
sudo $ZP $PROBE publish --user cloudimg --password "$AP" \
--key 'cloudimg/app/demo/temperature' \
--payload '{"celsius":21.4,"sensor":"rack-a"}'
wait
grep '^SAMPLE' /tmp/zenoh-sub.out
The SAMPLE line shows the key expression the sample arrived on and the payload, byte for byte as published. Waiting for SUBSCRIBER_READY rather than sleeping matters: a publisher that fires before its subscriber is declared proves nothing, and Zenoh will not replay it.

Note the second subscriber in that capture. It is listening on a key expression that should not match the published key, and it receives nothing — run at the same time as the one that does receive, so the pair together show the routing is actually selective rather than the harness being broken.
Key expressions in one minute
cloudimg/app/site-a/sensor/1— a literal key.cloudimg/app/site-a/sensor/*— one segment wildcard: matches.../sensor/1but not.../sensor/1/raw.cloudimg/app/**— any number of segments, including none.
Subscribers use expressions; publishers use concrete keys.
Query and reply
Publish/subscribe pushes. Sometimes you want to ask. A process declares itself queryable on a key expression and answers requests:
ZP=/usr/local/lib/cloudimg/zenoh-venv/bin/python
PROBE=/usr/local/lib/cloudimg/zenoh_probe.py
AP=$(sudo awk -F= '/^ZENOH_ADMIN_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
# a process that answers queries on this key
sudo $ZP $PROBE queryable --user cloudimg --password "$AP" \
--key 'cloudimg/app/demo/status' \
--payload 'rack-a: 12 nodes online' --duration 20 > /tmp/zenoh-q.out 2>&1 &
until grep -q QUERYABLE_READY /tmp/zenoh-q.out; do sleep 0.2; done
# ... and a SEPARATE process asking
sudo $ZP $PROBE get --user cloudimg --password "$AP" \
--key 'cloudimg/app/demo/status'
The REPLY line carries the key the answer came from and the value the queryable returned. Unlike a subscriber, a queryable is only consulted when someone asks — nothing is stored and nothing is pushed.
Storage that survives a restart
Anything written under cloudimg/store/** is persisted to disk by the storage manager and answered from disk afterwards. This is a real store, not a cache: upstream's example configuration uses an in-memory volume that loses everything on restart, and this image deliberately ships the filesystem backend instead.
Write a value:
ZP=/usr/local/lib/cloudimg/zenoh-venv/bin/python
PROBE=/usr/local/lib/cloudimg/zenoh_probe.py
AP=$(sudo awk -F= '/^ZENOH_ADMIN_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
sudo $ZP $PROBE publish --user cloudimg --password "$AP" \
--key 'cloudimg/store/fleet/site-a' \
--payload '{"site":"a","commissioned":"2026-07-26","nodes":12}'
Read it back — the reply comes from the storage plugin, not from a live publisher:
ZP=/usr/local/lib/cloudimg/zenoh-venv/bin/python
PROBE=/usr/local/lib/cloudimg/zenoh_probe.py
AP=$(sudo awk -F= '/^ZENOH_ADMIN_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
sleep 2
sudo $ZP $PROBE get --user cloudimg --password "$AP" \
--key 'cloudimg/store/fleet/**'
Each key is a real file on disk under /var/lib/zenohd/storage/store/, with its encoding and timestamp in an adjacent database:
sudo find /var/lib/zenohd/storage -type f | head

Authentication and access control, demonstrated
The security posture is not a claim in a configuration file — you can reproduce all of it.
A client with no credentials is refused, and so is one with the wrong password:
ZP=/usr/local/lib/cloudimg/zenoh-venv/bin/python
PROBE=/usr/local/lib/cloudimg/zenoh_probe.py
sudo $ZP $PROBE session
The cloudimg-app identity is confined to cloudimg/app/**. Publishing outside that subtree is silently dropped by the router — the message never reaches a subscriber, even one that is watching cloudimg/** with administrator rights:
ZP=/usr/local/lib/cloudimg/zenoh-venv/bin/python
PROBE=/usr/local/lib/cloudimg/zenoh_probe.py
APP=$(sudo awk -F= '/^ZENOH_APP_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
sudo $ZP $PROBE publish --user cloudimg-app --password "$APP" \
--key 'cloudimg/private/secrets' --payload 'this will not be delivered'

The last block in that capture is the point about the REST plugin: it answers on 127.0.0.1, it is refused on the VM's own address, and the router port on that same address is reachable — so the refusal is a real refusal and not a broken probe.
Changing the passwords
The dictionary is one user:password per line. To rotate:
sudo cp /etc/zenohd/credentials/zenoh-users.txt /root/zenoh-users.bak
Edit the file, keep the mode at 0640 root:zenohd, and restart the router. Every existing session is closed and every client must present the new password.
Adding your own identities
Add a line to the dictionary, then add a matching subject and policy to the access_control block in /etc/zenohd/zenohd.json5. The pattern is already there twice — copy the subject-app / policy-app pair and change the username and the key_exprs list. The default is deny, so an identity with no policy can authenticate but do nothing at all, which is a safe place to start.
Connect from your other machines
Install the same client library wherever your applications run — the API is available for Rust, Python, C, C++, Kotlin and TypeScript, and they all speak to this router.
pip install eclipse-zenoh==1.9.0
Then point a session at the router. In Python:
import json, zenoh
conf = zenoh.Config.from_json5(json.dumps({
"mode": "client",
"connect": {"endpoints": ["tcp/<router-ip>:7447"]},
"scouting": {"multicast": {"enabled": False}},
"transport": {"auth": {"usrpwd": {
"user": "cloudimg-app",
"password": "<app-password>",
}}},
}))
with zenoh.open(conf) as session:
session.put("cloudimg/app/site-a/sensor/1", "21.4")
Keep multicast.enabled false on your clients too unless you have deliberately turned discovery back on; otherwise they will spend time scouting for routers that will never answer.
Federating two routers
To span sites, point one router's connect.endpoints at another. Edit /etc/zenohd/zenohd.json5:
connect: {
endpoints: ["tcp/<other-router-ip>:7447"],
},
The connecting router is a client of the other as far as authentication goes, so give it credentials in the same block:
transport: {
auth: {
usrpwd: {
dictionary_file: "/etc/zenohd/credentials/zenoh-users.txt",
user: "cloudimg",
password: "<the other router's cloudimg password>",
},
},
},
Restart the router afterwards. Subscribers on either side then receive from publishers on both.
Encrypting the data plane with TLS
User/password authentication protects who may connect and never puts the password on the wire, but the payloads themselves travel in clear on tcp/. If your traffic crosses a network you do not control, add a TLS listener.
Generate a certificate authority and a server certificate whose SAN carries the router's address:
sudo install -d -o root -g zenohd -m 0750 /etc/zenohd/tls
cd /etc/zenohd/tls
sudo openssl ecparam -genkey -name prime256v1 -out ca.key
sudo openssl req -x509 -new -key ca.key -days 3650 -subj "/CN=zenoh-ca" -out ca.crt
Then add a tls/ endpoint alongside the TCP one and point the link configuration at the files:
listen: { endpoints: { router: ["tcp/0.0.0.0:7447", "tls/0.0.0.0:7448"] } },
transport: {
link: {
tls: {
root_ca_certificate: "/etc/zenohd/tls/ca.crt",
listen_private_key: "/etc/zenohd/tls/server.key",
listen_certificate: "/etc/zenohd/tls/server.crt",
enable_mtls: true,
},
},
},
With enable_mtls: true a client must present a certificate your CA issued as well as a valid username and password, and you can then use cert_common_names as an access-control subject in exactly the same way as usernames. Open 7448 in the NSG and leave 7447 closed if you want TLS to be the only way in.
Turning multicast discovery back on
Inside a network you control, multicast scouting means peers find the router without being configured with its address. It is off in this image because a router that answers discovery on a cloud VNet is answering strangers. To re-enable it, edit /etc/zenohd/zenohd.json5:
scouting: {
multicast: {
enabled: true,
listen: true,
},
},
Restart the router. Note that discovery is not authentication: a peer that discovers the router still has to present valid credentials, so this widens findability, not access.
Retention and disk safety
The router logs to /var/log/zenohd/zenohd.log, and /etc/logrotate.d/zenohd rotates it daily or as soon as it passes 64 MB, keeping seven compressed generations for at most 30 days. copytruncate is used deliberately: systemd holds the file open in append mode, so a rename-and-create rotation would leave the router writing into the rotated file forever.
cat /etc/logrotate.d/zenohd
sudo du -sh /var/log/zenohd /var/lib/zenohd/storage
The storage volume is your data and nothing rotates it for you. Every key written under cloudimg/store/** is a file that stays until something deletes it. Watch /var/lib/zenohd/storage, and if you expect meaningful volume, attach a data disk and mount it there rather than filling the OS disk. Deleting a key removes it:
ZP=/usr/local/lib/cloudimg/zenoh-venv/bin/python
PROBE=/usr/local/lib/cloudimg/zenoh_probe.py
AP=$(sudo awk -F= '/^ZENOH_ADMIN_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
sudo $ZP $PROBE get --user cloudimg --password "$AP" \
--key 'cloudimg/store/**'
Verify the whole thing, at any time
The appliance ships the same end-to-end check the image was gated on before release. It proves publish/subscribe between separate processes, a non-matching key expression receiving nothing, a query round trip, storage surviving a router restart, all three authentication refusals with a positive control, the access-control denial, the REST plugin's loopback confinement, log rotation on its own size threshold, and peak memory under load:
sudo /usr/local/sbin/zenoh-roundtrip-check.sh
It prints CHECK_PASSED along with the measured peak memory, or a single CHECK_FAILED_... line naming exactly what did not hold. It restarts the router as part of proving storage persistence, so run it in a maintenance window on a busy machine.
Troubleshooting
A client hangs or reports Unable to connect to any of [tcp/...]. Nine times out of ten this is credentials. Zenoh reports an authentication rejection as a connection failure because the router closes the link during the handshake. Check the password against /root/zenoh-credentials.txt, and check the NSG allows your address on 7447.
A subscriber receives nothing. Either the key expression does not match, or access control is denying it. Test with the administrator identity on cloudimg/** first: if that receives and cloudimg-app does not, the key is outside cloudimg/app/** and the router is dropping it by design.
The router will not start. Check the guard first — it runs before the socket opens and refuses on a published key or a bad dictionary:
# `systemctl status` exits non-zero when a unit is not running, which is the
# very case you are debugging — so it is not allowed to abort the rest.
sudo systemctl status zenohd.service --no-pager || true
sudo journalctl -u zenohd -n 40 --no-pager
A message beginning zenohd-secret-guard: REFUSING TO START names the exact reason. ConditionPathExists in the status output means first boot has not completed; check zenohd-firstboot.service.
First boot did not run. The sentinel is /var/lib/cloudimg/zenohd-firstboot.done:
sudo systemctl status zenohd-firstboot.service --no-pager || true
sudo journalctl -u zenohd-firstboot.service -n 40 --no-pager
ls -l /var/lib/cloudimg/zenohd-firstboot.done /var/lib/cloudimg/zenohd-bootstrap-ready 2>&1 || true
The REST plugin is not reachable from your laptop. That is correct and deliberate. Tunnel to it:
ssh -L 8000:127.0.0.1:8000 azureuser@<router-ip>
Then http://127.0.0.1:8000/cloudimg/store/** on your own machine.
Storage returns nothing after a restart. Give the storage plugin a few seconds after the router starts to re-open its directory, then query again. If the value is genuinely gone, check that /var/lib/zenohd/storage is on a filesystem with space.
Support
This image is published and supported by cloudimg. Eclipse Zenoh itself is an Eclipse Foundation project, dual licensed Apache-2.0 OR EPL-2.0; cloudimg elects Apache-2.0 for this image.
- Product documentation: zenoh.io
- Upstream project: github.com/eclipse-zenoh/zenoh
- cloudimg support: www.cloudimg.co.uk
24/7 support is included with the Marketplace subscription.