Schedule webhooks to private services over Tailscale

Setup & operations · Updated October 12, 2026

CronLite can join a Tailscale tailnet with an embedded node built on tsnet. No Tailscale app or sidecar runs next to it. Jobs can call internal services that have no public address, and the API, Admin UI and MCP endpoint can run with no public port. Everything is opt-in: the default binary contains no Tailscale code.

Build CronLite with Tailscale support

The Tailscale code is compiled only with the tailscale build tag. Pass it as a Docker build argument. A default build refuses the tailnet settings at startup and in cronlite validate, and its Admin UI says Tailscale is not built into the binary.

The tailnet needs MagicDNS. Create an auth key for a tagged node, such as tag:cronlite, and keep the node's state directory on persistent storage so restarts reuse its identity and certificate.

docker build --build-arg TAGS=tailscale -t cronlite:tailscale .

Deliver webhooks to tailnet services

Set WEBHOOK_ALLOW_TAILNET=true and list which hosts each namespace may call in WEBHOOK_TAILNET_HOSTS. A job can then target a MagicDNS name such as https://invoices.<tailnet>.ts.net/hook. Only subdomains of this node's own tailnet take the tailnet path; the name is resolved through MagicDNS, every answer must be a Tailscale node address, and only a checked IP is dialled.

Every other destination goes through the existing SSRF guard unchanged. IP literals, including 100.x addresses, stay blocked, as do Quad100, the 4via6 range and CronLite's own address. Each redirect hop is routed and checked again. A host outside the namespace's allowlist fails job creation with 422 webhook_host_not_allowed.

WEBHOOK_ALLOW_TAILNET=true
WEBHOOK_TAILNET_HOSTS=billing=invoices;ops=*
TS_AUTHKEY=tskey-auth-...
TS_HOSTNAME=cronlite

Serve CronLite only on the tailnet

With TS_LISTEN=true, the embedded node serves the REST API, /admin, /mcp, /health and /metrics at https://<TS_HOSTNAME>.<tailnet>.ts.net using a Tailscale-issued Let's Encrypt certificate. Plain HTTP redirects to the HTTPS name. Set TS_DISABLE_PUBLIC_LISTENER=true to remove the public port entirely; replace any platform health check that probes it.

Authentication is unchanged: being on the tailnet grants nothing by itself. Use Tailscale access rules to limit who can reach the node, and alert on the cronlite_tailnet_up gauge. /health?verbose=true reports tailnet up or down without failing the health check.

TS_LISTEN=true
TS_DISABLE_PUBLIC_LISTENER=true

Sign in to Admin as your Tailscale identity

Set TS_IDENTITY_LOGIN=true together with TS_LISTEN and ADMIN_ENABLED. Then grant people namespaces through an app capability in the tailnet policy file. On the tailnet listener the sign-in page offers Continue as <login>, with no API key to paste. A first sign-in can complete setup without a bootstrap token.

The visitor is identified with WhoIs on the connection, never from headers, and tagged devices are refused. Every request re-checks the grant, so removing it, tagging the device or turning the setting off ends the session at the next request. The REST API and MCP still use API keys.

{
  "src": ["autogroup:member"],
  "dst": ["tag:cronlite"],
  "ip":  ["tcp:443", "tcp:80"],
  "app": { "github.com/djlord-it/cronlite/cap/admin": [ { "namespaces": ["billing"] } ] }
}
  • Workspace → Tailscale in Admin shows node status, addresses, key expiry and the hosts this namespace may call.
  • Jobs that call tailnet hosts are marked Tailnet · allowed or Tailnet · blocked, with the reason on the job page.
  • The node warns when its key expires within 14 days.
Read the upstream reference