Network Deployment
Deploying ZeroClaw so it can receive inbound traffic: gateway exposure, webhook channels, tunnels, and LAN-only vs. public-facing configurations. Raspberry Pis and other home-network hosts are first-class targets here.
When inbound ports matter
| Mode | Inbound port? | Notes |
|---|---|---|
| Telegram (long-poll) | No | ZeroClaw polls api.telegram.org, works behind NAT |
| Matrix / Mattermost / Nextcloud Talk | No | Sync/WebSocket, outbound only |
| Discord / Slack (Socket Mode) | No | Outbound WebSocket |
Signal (signal-cli-rest-api) | No | Localhost container |
| Nostr / IMAP / MQTT | No | All outbound |
| Webhooks (GitHub, Slack Events API, WhatsApp, Nextcloud Talk bot, custom) | Yes | Public POST endpoint required |
| Gateway pairing from LAN | Yes (LAN-scope) | Bind to 0.0.0.0 or use a tunnel |
| Discord / Slack (HTTP Events) | Yes | If you don’t use Socket Mode |
Upshot: a Telegram-only bot runs on a Pi behind a consumer router with zero port forwarding. Anything webhook-based needs a reachable URL, which is where tunnels come in.
Binding the gateway
By default the gateway binds to 127.0.0.1, unreachable from other devices. Three options to expose it:
Option 1: Public bind (LAN)
Then any device on the LAN can reach http://<pi-ip>:42617. Doesn’t help for internet-reachable webhooks, your router’s public IP isn’t forwarded to the Pi.
Safety: allow_public_bind = true is required because binding to 0.0.0.0 is a significant posture change. Without it, the daemon refuses. This is deliberate.
Option 2: Tunnel (internet-reachable)
Then restart the daemon, the tunnel is managed declaratively from config, starting alongside the gateway.
The tunnel forwards from a public URL to the gateway on 127.0.0.1. No router config, no opened ports. Set tunnel.tunnel_provider to one of the supported values; each works similarly:
| Provider | Setup friction | Cost | Good for |
|---|---|---|---|
tailscale | Account + client | Free tier | Long-term, stable URLs |
cloudflare | Account + cloudflared + token | Free | Custom domains |
ngrok | Account + agent + token | Free with limits | Testing, short-lived |
pinggy | SSH, no account | Free tier | Quick one-shot URLs |
openvpn | Your own OpenVPN egress | Self-hosted | Existing VPN infra |
custom | A command under [tunnel.custom] | Depends | Anything else |
tunnel_provider = "none" (the default) keeps the gateway local with no tunnel. See the Config reference for each provider’s [tunnel.<provider>] fields.
Tailscale: WSS and enrollment
With tunnel_provider = "tailscale", the gateway is published with tailscale serve (or tailscale funnel when [tunnel.tailscale].funnel = true). When [wss] is enabled, the daemon also publishes the WSS RPC listener on the tailnet, along with the enrollment endpoint when [enroll] is enabled. Each one keeps its own port:
tailscale serve --tcp <wss.port> tcp://127.0.0.1:<wss.port>
tailscale serve --tcp <enroll.port> tcp://127.0.0.1:<enroll.port>
These are raw TCP passthrough forwards. TLS still terminates inside the daemon, so the WSS plane stays mutually authenticated, and the enrollment short-auth-string still binds the daemon’s own CA. Tailscale’s HTTPS proxy is not used for them, because it would strip the client certificate. A wildcard bind (0.0.0.0 / ::) is forwarded to loopback; a specific bind address is forwarded to that address.
Things to know:
- Tailnet-only, even with funnel. Funnel only listens on ports 443, 8443, and 10000, and putting the mTLS plane on the public internet should be your explicit choice. It is not turned on as a side effect of funneling the gateway. To reach WSS from outside the tailnet, use
[relay]. - Certificate names are automatic. When the WSS and enrollment listeners are reached over Tailscale, the daemon adds the node’s tailnet names to its auto-generated server certificate at startup. That covers the MagicDNS name (
<node>.<tailnet>.ts.net), its short name (<node>), the node’s tailnet IPs, and[tunnel.tailscale].hostnameif set. “Reached over Tailscale” means either this tunnel, or[wss].bind/[enroll].bindset directly to a tailnet address (100.64.0.0/10orfd7a:115c:a1e0::/48), with or without a tunnel. The names are looked up once per daemon start (and on each reload) and shared by both listeners, so WSS and enrollment always present the same certificate. Only the server certificate is regenerated when the names change. The CA stays the same, so enrolled clients keep working. If tailscaled can’t be reached at startup, or answers before it has received its network map (common right after boot), the daemon logs a warning and keeps every name the existing certificate already carries. New[wss].sansentries still apply, but removals wait until a start where tailscaled answers. On a brand-new install with tailscaled down, the certificate starts with only the names known without it (localhost,127.0.0.1,[wss].sans, and[tunnel.tailscale].hostname); restart once tailscaled is up to add the rest. A bring-your-owncert_pathcertificate is never modified. - Enrollment lockout is shared. Forwarded connections reach the enrollment endpoint from loopback. That means every tailnet enrollee shares one pairing-attempt lockout, and repeated bad codes from one tailnet peer temporarily block enrollment for the others. The lockout fails closed. It never weakens the pairing-code gate.
- The forwards are foreground
tailscale servesessions owned by the gateway, so they are withdrawn when the gateway stops. If one exits on its own later, the daemon logs atailscale serve --tcp exited after publicationwarning; restart the gateway to republish it. - Enrollment follows the CA mode. With a bring-your-own client CA (
[wss.client_auth]enabled withca_cert_path), the daemon holds no signing key and does not run the enrollment endpoint, so it is not published either.
Option 3: Reverse proxy
Run nginx / Caddy / Traefik in front of the gateway. Terminate TLS there, proxy to localhost:42617. Suitable for:
- Servers with a real public IP
- Existing reverse-proxy setups with Let’s Encrypt
- Serving multiple services on the same host
A minimal Caddy config:
agent.example.com {
reverse_proxy localhost:42617
}
The gateway stays bound to 127.0.0.1, the proxy does the listening.
Generic gateway webhook authentication
The gateway’s POST /webhook and SOP-only POST /sop/* routes can require an
exact shared-secret header independently of pairing:
[gateway]
webhook_secret = "replace-with-a-random-secret"
Send the value as X-Webhook-Secret. When require_pairing = true and
webhook_secret is set, callers must send both the paired bearer token and
the webhook secret. The generic gateway secret is deliberately separate from
[channels.webhook.<alias>].secret; channel aliases run their own listeners
and use body HMAC verification instead.
Gateway config writes take effect through the live gateway config view. Direct file edits require the normal daemon reload (or a standalone gateway restart).
Remote daemon reload
POST /admin/reload re-reads config.toml and rebuilds every subsystem in place (same PID, sub-second downtime). It is the supported way to apply config changes without a full restart. By default it only accepts loopback callers, so a remote dashboard or curl from another machine gets 403 Forbidden.
To allow authenticated remote reloads:
[gateway]
allow_remote_admin = true # off by default
require_pairing = true # required for remote reload (also the default)
With this enabled, a non-loopback caller may hit /admin/reload only if it also passes pairing authentication (Authorization: Bearer <token>). Loopback callers (the local CLI) are always allowed and need no token. /admin/shutdown and the pairing-code endpoints remain localhost-only regardless of this flag.
Because remote access is enforced through pairing, allow_remote_admin has no effect unless require_pairing is also on: if pairing is disabled, a remote caller cannot be authenticated, so the request is rejected with 403 Forbidden rather than allowed anonymously. This makes it impossible to expose an unauthenticated remote reload by flipping a single flag.
Safety: leave allow_remote_admin off unless you specifically need to reload from another host. Keep require_pairing = true (the default) so reloads can’t be triggered anonymously.
Raspberry Pi deployment
Prerequisites
- Raspberry Pi 3/4/5 (or similar SBC) with Raspberry Pi OS or Alpine
- Network connectivity (WiFi or Ethernet)
- Optional: USB peripherals for hardware integration
Install
Clone and run the installer. With no flags it drops into an interactive picker where you choose the build type and which features to compile in, including the hardware features for GPIO/I2C/SPI. On the Pi it also uses the Pi-tuned cargo profiles; see Raspberry Pi setup for swap setup and the per-model build matrix.
Raspberry Pi OS
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
./install.sh
Alpine
apk add curl rust cargo openssl-dev pkgconf git
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
./install.sh
Grants access to GPIO, I2C, SPI via rppal when you pick the hardware features.
The stock service unit already adds the user to the gpio, spi, i2c groups.
Checklist
- Install the binary (
./install.sh, pick your features in the picker) - Run
zeroclaw quickstart - Configure your channels. Telegram needs no port; webhooks need a tunnel
- Install the service:
zeroclaw service install && zeroclaw service start - For LAN access: set
[gateway] host = "0.0.0.0"+allow_public_bind = true - For webhooks: configure
[tunnel]with a provider
Alpine Linux (OpenRC)
OpenRC services run system-wide. Install as root:
sh
sudo zeroclaw service install
Creates:
/etc/init.d/zeroclaw: init script/etc/zeroclaw/: config directory/var/log/zeroclaw/: log files
Enable and start:
sh
sudo rc-update add zeroclaw default
sudo rc-service zeroclaw start
sudo rc-service zeroclaw status
Logs:
sh
sudo tail -f /var/log/zeroclaw/error.log
OpenRC notes
- Service runs as
zeroclaw:zeroclaw(least privilege) - System-wide only: no user-level OpenRC services
- All service operations need
sudo
Telegram polling caveat
Telegram Bot API’s getUpdates is single-poller per bot token. You cannot run two instances with the same token; the second gets Conflict: terminated by other getUpdates request.
If you see this:
-
ps aux | grep zeroclawand confirm only one daemon is running -
Check you don’t have
cargo run --bin zeroclaw -- channel start telegramfrom a dev session hanging around -
If stale, reset Telegram’s poll session:
sh
curl -X POST "https://api.telegram.org/bot$TOKEN/close"
Exposing webhooks safely
A publicly-reachable webhook URL is attack surface. At minimum:
- HMAC signature verification:
secretconfigured on each webhook channel - Source IP allowlist where the service has fixed egress IPs (GitHub, AWS SNS)
- Rate limiting:
rate_limit_per_secin the webhook channel config
See Channels → Webhooks for the full set of knobs.
See also
- Setup → Container: Docker-specific network config
- Setup → Service management: platform service integration
- Operations → Overview
- Security → Overview