Deployment guide

Zenoh

Overview

This guide covers the deployment and use of Eclipse Zenoh 1.9.0 on Ubuntu 24.04 on Azure using SecureAnyCloud 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. Its time and space efficiency is 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. The router is the infrastructure piece. It is the node that your peers and clients connect to. It routes their data, answers their queries and holds the storage they read back later. It is not a library and not a demo. You deploy it once and point everything else at it.

What a router gives you

  • Publish / subscribe: A process anywhere in your estate publishes on a key expression such as sac/app/site-a/sensor/42. Every subscriber whose expression matches receives it. Wildcards (*, **) make the routing declarative, so you do not wire it 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. A subscriber in one region transparently receives from a publisher in another.

The shape of the deployment

  • Port 7447 (TCP): The Zenoh router. Every peer and client session lands here. Authentication is mandatory.
  • Port 8000 (TCP): The REST plugin, an HTTP view of the same key space. It is bound to 127.0.0.1 only. The security section below explains why that matters more than it looks.
  • Port 22 (TCP): SSH.

The image sets up these key expressions out of the box:

sac/app/** the application key space, usable by both shipped identities sac/store/** persisted to disk, readable again after a restart

Security by design: 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 reasonable default for a laptop experiment. It is unacceptable 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 begins. A client with the wrong password is refused at the handshake.
  • The credentials are generated on your VM, on its first boot. They are two 32-character secrets from openssl. Nothing is baked into the image, so no two customers ever share a Zenoh login. The router cannot start until first boot has written them.
  • Access control is on, and it defaults to deny. Two identities ship. SecureAnyCloud is the administrator and may use any key expression. sac-app is a least-privilege application identity confined to sac/app/**. The router drops a publish from sac-app outside that subtree.
  • 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, so it is 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 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. If you accidentally copy an upstream example into your dictionary, the router stops instead of 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. SecureAnyCloud 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. This is a classification quirk of dual-licence headers, and the file itself is unambiguous.

What you get:

  • 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 sac/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 that defaults to deny, and a committed-secret guard.
  • Log rotation and retention for the router's own log.

Before you start

You need an Azure subscription, permission to create a virtual machine and an SSH key pair. Nothing else is required, because 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 leaves 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 image terms accept --urn sac1647283583153:zenoh-ubuntu-24-04:default:latest
az vm create \
  --resource-group zenoh-rg \
  --name zenoh-router \
  --image sac1647283583153: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. Connect over SSH, then read the file:

ssh azureuser@<vm-ip>
sudo cat /root/zenoh-credentials.txt

The file shows 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=sac ZENOH_ADMIN_PASSWORD=<32 hex characters, generated on this VM> ZENOH_APP_USER=sac-app ZENOH_APP_PASSWORD=<32 hex characters, generated on this VM> ZENOH_APP_KEY_PREFIX=sac/app ZENOH_STORAGE_KEY_PREFIX=sac/store ZENOH_CLIENT_PYTHON=/usr/local/lib/sac/zenoh-venv/bin/python

Keep the administrator password for operations and hand the sac-app credentials to your applications. That is why the image ships two identities. An application that only needs sac/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 listens on 127.0.0.1:8000 and nowhere else.

The effective security posture is read back through Zenoh's own configuration parser rather than by grepping the file. A setting that is present but overridden, misnested or commented out therefore 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 are two independent clients talking through the router.

This block starts a subscriber and waits until it is declared. It then publishes from a separate process and shows what arrived. Run the whole block at once:

ZP=/usr/local/lib/sac/zenoh-venv/bin/python PROBE=/usr/local/lib/sac/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 sac --password "$AP" \
  --key 'sac/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 sac --password "$AP" \
  --key 'sac/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 does not replay the message.

Routing is selective. The verification script described under "Verify the whole thing, at any time" also runs a subscriber on a key expression that does not match the published key, and that subscriber receives nothing.

Key expressions in one minute

  • sac/app/site-a/sensor/1 is a literal key.
  • sac/app/site-a/sensor/* is a one-segment wildcard. It matches .../sensor/1 but not .../sensor/1/raw.
  • sac/app/** matches any number of segments, including none.

Subscribers use expressions. Publishers use concrete keys.

Query and reply

Publish/subscribe pushes data. Sometimes you want to ask for it instead. A process declares itself queryable on a key expression and answers requests:

ZP=/usr/local/lib/sac/zenoh-venv/bin/python PROBE=/usr/local/lib/sac/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 sac --password "$AP" \
  --key 'sac/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 sac --password "$AP" \
  --key 'sac/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 consulted only when someone asks. Nothing is stored and nothing is pushed.

Storage that survives a restart

Anything written under sac/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/sac/zenoh-venv/bin/python PROBE=/usr/local/lib/sac/zenoh_probe.py
AP=$(sudo awk -F= '/^ZENOH_ADMIN_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
sudo $ZP $PROBE publish --user sac --password "$AP" \
  --key 'sac/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/sac/zenoh-venv/bin/python PROBE=/usr/local/lib/sac/zenoh_probe.py
AP=$(sudo awk -F= '/^ZENOH_ADMIN_PASSWORD=/{print $2}' /root/zenoh-credentials.txt) sleep 2
sudo $ZP $PROBE get --user sac --password "$AP" \
  --key 'sac/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 just 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/sac/zenoh-venv/bin/python PROBE=/usr/local/lib/sac/zenoh_probe.py
sudo $ZP $PROBE session

The sac-app identity is confined to sac/app/**. The router silently drops a publish outside that subtree. The message never reaches a subscriber, even one that is watching sac/** with administrator rights:

ZP=/usr/local/lib/sac/zenoh-venv/bin/python PROBE=/usr/local/lib/sac/zenoh_probe.py
APP=$(sudo awk -F= '/^ZENOH_APP_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
sudo $ZP $PROBE publish --user sac-app --password "$APP" \
  --key 'sac/private/secrets' --payload 'this will not be delivered'

The REST plugin follows the same principle. It answers on 127.0.0.1 and is refused on the VM's own address, while the router port on that same address is reachable. The refusal is therefore a real refusal and not a broken probe.

Changing the passwords

The dictionary holds one user:password per line. To rotate the passwords, first back up the file:

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 can do nothing at all. That 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 all of them speak to this router.

pip install eclipse-zenoh==1.9.0

Then point a session at the router. In Python, use:

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": "sac-app", "password": "<app-password>", }}}, })) with zenoh.open(conf) as session: session.put("sac/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 spend time scouting for routers that 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"], },

For authentication, the connecting router acts as a client of the other router. Give it credentials in the same block:

transport: { auth: { usrpwd: { dictionary_file: "/etc/zenohd/credentials/zenoh-users.txt", user: "sac", password: "<the other router's sac password>", }, }, },

Restart the router afterwards. Subscribers on either side then receive from publishers on both sides.

Encrypting the data plane with TLS

User/password authentication controls who may connect and never puts the password on the wire. The payloads themselves, however, 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 certificate 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 that your CA issued, as well as a valid username and password. You can then use cert_common_names as an access-control subject in exactly the same way as usernames. To make TLS the only way in, open 7448 in the NSG and leave 7447 closed.

Turning multicast discovery back on

Inside a network you control, multicast scouting lets 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. Discovery is not authentication. A peer that discovers the router still has to present valid credentials, so this setting 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. It keeps seven compressed generations for at most 30 days. The configuration uses copytruncate 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 sac/store/** is a file that stays until something deletes it. Watch /var/lib/zenohd/storage. If you expect meaningful volume, attach a data disk and mount it there rather than filling the OS disk. To see what is stored, query the whole store:

ZP=/usr/local/lib/sac/zenoh-venv/bin/python PROBE=/usr/local/lib/sac/zenoh_probe.py
AP=$(sudo awk -F= '/^ZENOH_ADMIN_PASSWORD=/{print $2}' /root/zenoh-credentials.txt)
sudo $ZP $PROBE get --user sac --password "$AP" \
  --key 'sac/store/**'

Verify the whole thing, at any time

The appliance ships the same end-to-end check that the image was gated on before release. It proves these behaviours: 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.

Run the check with:

sudo /usr/local/sbin/zenoh-roundtrip-check.sh

The script 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 to prove 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/...]. The usual cause 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 that 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 sac/** first. If that identity receives and sac-app does not, the key is outside sac/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 that begins zenohd-secret-guard: REFUSING TO START names the exact reason. ConditionPathExists in the status output means first boot has not completed. In that case, check zenohd-firstboot.service.

First boot did not run. The sentinel is /var/lib/sac/data/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/sac/data/zenohd-firstboot.done /var/lib/sac/data/zenohd-bootstrap-ready 2>&1 || true

The REST plugin is not reachable from your laptop. This is correct and deliberate. Open an SSH tunnel to it:

ssh -L 8000:127.0.0.1:8000 azureuser@<router-ip>

Then open http://127.0.0.1:8000/sac/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

SecureAnyCloud publishes and supports this image. Eclipse Zenoh itself is an Eclipse Foundation project, dual licensed Apache-2.0 OR EPL-2.0. SecureAnyCloud elects Apache-2.0 for this image.

  • Product documentation: zenoh.io
  • Upstream project: github.com/eclipse-zenoh/zenoh
  • SecureAnyCloud support: secureanycloud.com

24/7 support is included with the Marketplace subscription.

Deploy on Azure

Launch Eclipse Zenoh 1.9.0 on Ubuntu 24.04 on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure