Skip to content

Exposure modes

Exposure mode controls how the daemon is reachable over the network. Most setups use local mode. You only need a non-local mode when something external needs to reach the daemon: GitHub webhooks, CI, a second machine, a reverse proxy, or another container.

  • Netclaw installed (see Installation if needed). Exposure mode is configured after install via netclaw config, not during netclaw init.
  • For reverse proxy: a working proxy already configured, a non-loopback internal IP for the daemon, and the proxy source IP or CIDR ready for TrustedProxies.
  • For Tailscale modes: tailscaled installed and running.
  • For Cloudflare Tunnel: cloudflared installed and configured.
ModeConfig valueRequired processReachabilityRisk
LocallocalNoneLoopback onlyLowest
Reverse Proxyreverse-proxyYour reverse proxyWhatever the proxy exposesMedium
Tailscale Servetailscale-servetailscaledYour Tailscale network (called a tailnet), HTTPSLow
Tailscale Funneltailscale-funneltailscaledPublic internet via TailscaleHigh
Cloudflare Tunnelcloudflare-tunnelcloudflaredPublic internet via CloudflareHigh

Configure the exposure mode via netclaw config - navigate to Security & Access → Exposure Mode - or set Daemon.ExposureMode directly in netclaw.json.

The Exposure Mode screen with five options, Local checked and marked recommended, and a warning triangle on Tailscale Funnel and Cloudflare Tunnel

The Exposure Mode picker in netclaw config → Security & Access. The triangle marks the two modes that reach the public internet.

Options marked with a warning triangle expose the daemon to the public internet. Tailscale Serve is the recommended remote mode: tailnet-only access, no public exposure.

In local mode loopback is trusted only when the request looks local too. The Host header must be localhost or a loopback IP literal such as 127.0.0.1, and an Origin header, if there is one, must be http:// plus that same host. Anything else gets 401:

RequestResult
http://127.0.0.1:5199/api/health/status or http://localhost:5199/...200
Same call with Host: netclaw:5199, or any DNS name that resolves to loopback, host.docker.internal included401
A browser page on another local port, such as http://localhost:3000, calling the daemon401 (the Origin doesn’t match)

A web app should call the daemon from its server process, or pair with netclaw daemon pair and send the device token. Anonymous routes (/api/health/ready, webhooks, the Mattermost callback) answer regardless.

{
"Daemon": {
"Host": "10.0.0.5",
"ExposureMode": "reverse-proxy",
"TrustedProxies": ["10.0.0.10"]
}
}

Use this when nginx, Caddy, Traefik, HAProxy, or another reverse proxy is the public edge.

In this mode:

  • Daemon.Host must be a non-loopback address: an internal IP, or 0.0.0.0 to listen on every interface. 127.0.0.1, ::1 and localhost are rejected in reverse-proxy mode.
  • TrustedProxies must list the proxy’s source IP or CIDR. Forwarded headers are only honored from those peers.

If the proxy runs on the same machine, the final hop into netclaw still needs to target a non-loopback address. A same-host reverse proxy is fine, but that hop can’t use loopback. The local CLI maps 0.0.0.0 back to 127.0.0.1 on its own.

In Docker the container’s address changes on every start, so bind 0.0.0.0 and trust the network that holds your proxy:

Terminal window
docker run -d --name netclaw --network proxy-net \
-e NETCLAW_Daemon__ExposureMode=reverse-proxy \
-e NETCLAW_Daemon__Host=0.0.0.0 \
-e NETCLAW_Daemon__TrustedProxies__0=172.16.0.0/12 \
ghcr.io/netclaw-dev/netclaw:0.28.2

From another container on that network, /api/health/ready returns 200 with no credentials, and /api/health/status returns 401 without a device token.

Set the mode via netclaw config → Security & Access → Exposure Mode, or directly edit the Daemon section of ~/.netclaw/config/netclaw.json.

No Daemon section needed. The daemon binds to 127.0.0.1:5199 and is only reachable from the same machine.

{}
{
"Daemon": {
"ExposureMode": "tailscale-serve"
}
}

Tailscale Serve creates an HTTPS endpoint on your tailnet that proxies to the daemon’s local port. Only devices on your tailnet can reach it. Then run tailscale serve to proxy your tailnet hostname to 127.0.0.1:5199. The daemon refuses to start unless tailscaled is running or SkipTunnelProcessCheck is set.

{
"Daemon": {
"ExposureMode": "tailscale-funnel"
}
}

Funnel extends Serve to the public internet. Anyone with the URL can reach the daemon, though netclaw’s device authentication still applies. Configure it with tailscale funnel. Only use Funnel when you need public internet access - Tailscale Serve covers everything else.

{
"Daemon": {
"ExposureMode": "cloudflare-tunnel"
}
}

Routes traffic through Cloudflare’s network to the daemon. Set up cloudflared with a Cloudflare Tunnel pointed at 127.0.0.1:5199, and pair it with a Cloudflare Access policy to restrict who can connect.

If cloudflared runs outside the daemon’s process namespace, set Daemon.SkipTunnelProcessCheck to true so startup validation skips the local process probe without skipping the rest of the auth checks.

FieldTypeDefaultNotes
Hoststring127.0.0.1IP address the daemon binds to
Portint5199TCP port
ExposureModestringlocallocal, reverse-proxy, tailscale-serve, tailscale-funnel, or cloudflare-tunnel
TrustedProxiesstring[][]Required in reverse-proxy; literal IPs or CIDRs only
SkipTunnelProcessCheckboolfalseSkip local tunnel process detection for sidecar or host-managed tunnel topologies
{
"Daemon": {
"Host": "127.0.0.1",
"Port": 5199,
"ExposureMode": "tailscale-serve"
}
}

Case-insensitive - reverse-proxy, ReverseProxy, and REVERSE-PROXY all work, same as the tunnel modes.

Docker users: a published port doesn’t help in a tunnel mode, because the daemon still binds loopback inside the container. Run the tunnel agent in the same network namespace (Compose network_mode: "service:netclaw", or a sidecar in the same pod) and set Daemon.SkipTunnelProcessCheck to true, since the agent isn’t in the daemon’s process namespace. A container started this way reaches healthy and listens on 127.0.0.1:5199. The image’s health check runs netclaw daemon status, which follows Daemon.Port, so a custom port stays healthy. For a reverse proxy, use the 0.0.0.0 form above. See Run netclaw in Docker.

Override any field with NETCLAW_Daemon__ prefixed env vars:

Terminal window
NETCLAW_Daemon__ExposureMode=reverse-proxy
NETCLAW_Daemon__Host=10.0.0.5
NETCLAW_Daemon__Port=5199
NETCLAW_Daemon__TrustedProxies__0=10.0.0.10

Double underscores separate path segments, following the .NET configuration convention.

Editing Daemon.Host, Daemon.Port or Daemon.ExposureMode in netclaw.json restarts the daemon in-process like any other config change: it drains active sessions, rebinds and comes back. Restart it yourself only when the value comes from an environment variable or the unit file.

Terminal window
# systemd
systemctl --user restart netclaw
# Docker
docker restart netclaw

Inbound webhooks let external services like GitHub or CI trigger autonomous runs with an HTTP POST. Routes are served whenever Webhooks.Enabled is true, in every mode. In local mode only processes on the daemon host can reach /api/webhooks/<route>, so a service on the internet needs a tunnel or a reverse proxy in front of it.

Non-local modes require at least one paired device or an alternative remote authentication scheme. Without one, the daemon refuses to start because remote clients have no way to authenticate.

On fresh setup-owned installs, netclaw automatically creates a one-time bootstrap path for the first non-local start. Before the first successful non-local daemon start, it seeds one local paired device and matching local client token when no paired devices already exist. That covers the init wizard, first Docker boot, and manual setups that still use the daemon’s local state directory.

After the first successful non-local start, that auto-seeding stops. Normal CLI use is paired-device auth from that point on.

If the daemon is already running and you need to add another device, pair from the daemon host:

Terminal window
netclaw daemon pair

The command prints a code. In a non-local mode the next line reads netclaw pair <https-address>: replace the placeholder with the HTTPS address your proxy or tunnel publishes.

If the mode requires tailscaled or cloudflared and that process isn’t running, the daemon logs this to ~/.netclaw/logs/daemon-*.log. docker logs shows the same message as an exception, starting Unhandled exception. System.InvalidOperationException: and without the Daemon startup aborted: prefix or the Remediation: label, just before Aborted (core dumped) and a restart:

Daemon startup aborted: Tunnel prerequisite not met: ExposureMode='tailscale-serve' requires 'tailscaled' to be running unless Daemon.SkipTunnelProcessCheck=true is explicitly set for a sidecar or host-managed tunnel topology. Remediation: Start 'tailscaled' locally, or set Daemon.SkipTunnelProcessCheck=true only when the tunnel is intentionally managed outside the Netclaw process namespace.

If no paired devices exist and no alternative auth scheme is configured:

Daemon startup aborted: No remote authentication available: ExposureMode='tailscale-serve' requires either at least one paired device or an alternative remote auth scheme. Remediation: Run 'netclaw daemon pair' to pair a device, or check your auth configuration before exposing the daemon remotely.

Both are fatal. The daemon won’t start until you fix the underlying issue. Under systemd it restarts every five seconds. In Docker the entrypoint retries with a delay that grows to 60 seconds.

In reverse-proxy mode, startup also fails if the daemon is still bound to loopback or if TrustedProxies is empty or malformed.

Run netclaw doctor first. It has a dedicated exposure-mode check, and it’s one of several checks beyond config validity.

netclaw doctor output: 24 checks, all PASS, ending with doctor exit code: 0

The exposure-mode line near the bottom reports local and the address it’s bound to.

Symptom: Startup aborted with Tunnel prerequisite not met.

Start the required process first:

Terminal window
# Tailscale modes
sudo systemctl start tailscaled
# Cloudflare Tunnel
sudo systemctl start cloudflared

Or switch to local mode if you don’t need remote access:

{
"Daemon": {
"ExposureMode": "local"
}
}

Symptom: Startup aborted with No remote authentication available.

If this is a brand-new non-local install, check whether the daemon actually completed its first successful start. The one-shot bootstrap only happens before that first successful non-local startup.

If the daemon is already running, pair from the daemon host:

Terminal window
netclaw daemon pair

If the device store was lost after the first successful non-local start and the daemon will not start anymore, temporarily switch Daemon.ExposureMode to local or restore devices.json and secrets.json from backup. Then start the daemon, run netclaw daemon pair, and switch back.

You can also run netclaw init on an existing install - it shows an action menu with options to open the configuration editor, redo identity, or start over.

Symptom: Startup aborted because Daemon.ExposureMode is reverse-proxy but Daemon.Host is 127.0.0.1, ::1 or localhost.

Bind netclaw to a non-loopback address and point the proxy at it instead of loopback:

{
"Daemon": {
"Host": "10.0.0.5",
"ExposureMode": "reverse-proxy",
"TrustedProxies": ["10.0.0.10"]
}
}

Symptom: netclaw doctor fails the exposure-mode check with Invalid local topology: Daemon.Host '0.0.0.0' is not a loopback address. and exits 1. The daemon itself refuses to start in this combination.

Either bind to loopback:

{
"Daemon": {
"Host": "127.0.0.1"
}
}

Or switch to the exposure mode that reflects how the daemon is actually reachable:

{
"Daemon": {
"Host": "127.0.0.1",
"ExposureMode": "tailscale-serve"
}
}