Application Infrastructure Azure

Eclipse Zenoh 1.9.0 on Ubuntu 24.04 on Azure User Guide

| Product: Eclipse Zenoh 1.9.0 on Ubuntu 24.04 on Azure

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: cloudimg is the administrator and may use any key expression, and cloudimg-app is a least-privilege application identity confined to cloudimg/app/**. A publish from cloudimg-app outside 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.0 would be an unauthenticated door straight past every rule above. It is bound to 127.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:routerpasswd and 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 zenohd 1.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-zenoh Python 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.

Terminal showing zenohd reporting version 1.9.0 with zenohd.service active, the listening surface with 0.0.0.0:7447 for the router and 127.0.0.1:8000 for the REST plugin, then the effective security posture read back with Eclipse Zenoh's own configuration parser confirming access control enabled true, default permission deny, multicast scouting off, admin space off, the REST plugin bound to 127.0.0.1:8000 and a persistent fs storage volume, then the committed-secret deny-list holding 11 published key digests and 8 credential literals, and finally the per-VM credentials record at permissions 600 owned by root listing the cloudimg administrator and the cloudimg-app identity confined to cloudimg/app/**

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.

Terminal showing two subscriber processes declared, one on a matching key expression and one on a deliberately non-matching control expression, then a separate publisher process putting a JSON payload of celsius 21.4 sensor rack-a, then the matching subscriber receiving exactly that sample while the non-matching subscriber reports it received 0 samples, and finally a queryable process declared on a status key answering a get issued by a different process with the reply rack-a 12 nodes online

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/1 but 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

Terminal showing a JSON value published to a cloudimg store fleet key, read back through the storage plugin, then located as a real file on disk under /var/lib/zenohd/storage/store/fleet, then the zenohd service restarted entirely and reporting active with a one second uptime, and finally the same key queried again after the restart returning the identical JSON value, demonstrating that this is a store and not a cache

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'

Terminal showing three authentication attempts that are all refused, a client with no credentials at all, a client with the right username and a forged password, and a client using the credentials Eclipse Zenoh publishes in its own test suite, followed by this VM's real credential opening a session successfully so the refusals are genuine rather than a lockout, then an access control demonstration where the confined cloudimg-app identity publishes twice against one administrator subscriber and the message outside its subtree is delivered 0 times while the one inside is delivered 1 time, and finally the REST plugin answering HTTP 200 on loopback while being refused on the VM's own address where the router port is reachable

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.

24/7 support is included with the Marketplace subscription.