Tailscale Integration

Serve your application on a Tailscale network, expose it to the public internet via Funnel, or run as a standalone node with no daemon — all from elide orb. This page covers the three Tailscale modes, DERP relay co-hosting, MagicDNS, multi-network orchestration, and Headscale support.

---

Three modes

Daemon mode (--tailscale)

Connect to a running tailscaled daemon. Elide binds to the node's Tailscale IP, provisions a browser-trusted TLS certificate from the Tailscale CA, registers MagicDNS, and identifies incoming peers via whois.

bash
 elide orb --config server.pkl --tailscale

What happens:

  • Detects the node's Tailscale IP and DNS name (e.g. 100.64.0.42, my-node.tail1234.ts.net)
  • Provisions a TLS certificate automatically — trusted by all tailnet devices, no browser warnings
  • Registers MagicDNS so Elide resolves other tailnet peers by name
  • Enables whois-based peer identification for the TailscaleAuth middleware (auto-injected)
Requirements: tailscaled running and enrolled (tailscale up). Unix only (Linux, macOS).

Funnel mode (--funnel)

Expose the server to the public internet through Tailscale's relay infrastructure. Anyone can reach it at https://your-node.tail1234.ts.net.

bash
 elide orb --config server.pkl --funnel
--funnel implies --tailscale. Elide configures the Tailscale serve config via the local API at startup and tears it down on shutdown.
Funnel traffic originates from external clients, not tailnet members. The TailscaleAuth middleware is automatically skipped because whois cannot identify external callers.
Requirements: Same as daemon mode, plus the Tailscale ACL policy must allow Funnel for the node. See Tailscale Funnel docs.

Standalone mode (--tailscale-direct)

Run as a self-contained Tailscale node without tailscaled. Elide embeds the full TS2021 control plane client (Noise IK encryption), WireGuard data plane (userspace via boringtun), DERP relay client, and DISCO endpoint discovery.

bash
 elide orb --config server.pkl --tailscale-direct --tailscale-auth-key tskey-auth-...

What happens:

  • Authenticates to the Tailscale coordination server using the pre-auth key
  • Polls the network map to discover peers and IP assignments
  • Registers MagicDNS entries for all tailnet peers
  • Spawns the WireGuard data plane
  • Bridges inbound TCP connections from the tunnel to the HTTP server
  • Starts a background thread for continuous map polling, STUN discovery, and MagicDNS updates
Pass the auth key via environment variable instead of the command line: set TAILSCALE_AUTH_KEY and omit —tailscale-auth-key.

When to use standalone mode

  • Containers and serverless — single binary joins the tailnet, no sidecar daemon
  • Edge deployments — minimal footprint, no privileged socket access
  • Ephemeral workloads — pre-auth key via env var, tear down when done
  • CI/CD — expose preview environments on the tailnet without system-wide Tailscale install
  • Air-gapped / self-hosted — point controlUrl at a Headscale instance

Generating a pre-auth key

Via the Tailscale admin console or the CLI:

bash
# Reusable, ephemeral, tagged key (recommended for automation)
 tailscale api keys create --reusable --ephemeral --tags tag:elide-server

Standalone mode comparison

FeatureDaemon modeStandalone mode
Tailscale IP assignmentYesYes
MagicDNS for peer namesYesYes
Network map pollingYesYes
WireGuard tunnel (data plane)YesYes (userspace via boringtun)
DISCO NAT traversalHandled by tailscaledBuilt-in state machine
STUN endpoint discoveryHandled by tailscaledPeriodic via background thread
Browser-trusted TLS (via Tailscale)YesNo (use —auto-https or PKL tls)
Tailscale FunnelYesNo
Whois peer identificationYesNot yet
ACL packet filteringHandled by tailscaledParsed from netmap (not yet applied)
TUN device creationNo (tailscaled owns tun)Yes (—tun flag)
---

DERP relay

DERP (Designated Encrypted Relay for Packets) relays provide fallback connectivity when direct peer-to-peer WireGuard paths cannot be established due to NAT. Elide can co-host a DERP relay alongside the HTTP server:

bash
 elide orb --config server.pkl --tailscale --derp

The relay listens on port 3340 by default and shuts down gracefully on exit. Override the port:

bash
 elide orb --config server.pkl --tailscale --derp --derp-port 4000

DERP can also be configured in PKL for standalone mode:

pkl
networks {
  ["corp"] {
    tailscale {
      direct = true
      authKey = env("TS_KEY")
      derp {
        server = true
        serverPort = 3478
        customRelays { "https://derp.internal.example.com:443" }
      }
    }
  }
}

For a standalone DERP relay without the HTTP server, use elide tun derp:

bash
 elide tun derp --port 3340

---

MagicDNS

When Tailscale is active (any mode), Elide builds a DNS resolver from the Tailscale peer table and registers it as the process-wide resolver. All DNS lookups within the server — HTTP client requests, TLS connections, STUN, DERP — resolve tailnet names without system DNS.

Resolution order:

1. If the hostname matches a known tailnet peer (e.g. other-node.tail1234.ts.net), resolve directly from the cached peer table. 2. Otherwise, fall back to system DNS. 3. IP literals bypass DNS entirely.

The resolver is thread-safe and registered once at startup. In standalone mode, the background control thread updates the peer table on each netmap poll, so new peers become resolvable within seconds.

MagicDNS currently only resolves within the Elide process. External processes cannot resolve tailnet names through Elide's resolver. A local DNS forwarder is planned.

---

Multi-network orchestration

elide orb supports multiple named network attachments in a single process. Each network has its own data plane, routing rules, and identity.
pkl
amends "elide:serve/ElideServer.pkl"

networks {
  // Production tailnet with standalone mode
  ["production"] {
    tailscale {
      direct = true
      authKey = env("TS_PROD_KEY")
      disco { heartbeatInterval = 2.s; reprobeInterval = 25.s }
      dataPlane { mtu = 1280; bufferPoolSize = 256 }
    }
    tunnel {
      bridgeInbound = true
      bridgeOutbound = true
      listenPort = 443
    }
  }

  // Office VPN via raw WireGuard
  ["office-vpn"] {
    wireguard { configFile = "./wg-office.conf" }
    tunnel { bridgeOutbound = true }
  }

  // Staging tailnet via Headscale
  ["staging"] {
    tailscale {
      direct = true
      authKey = env("TS_STAGING_KEY")
      controlUrl = "https:<<>>
    }
    tunnel { bridgeInbound = true }
  }
}

servers {
  ["api.example.com"] {
    handler = new ReverseProxy {
      <<>>
      upstreams { "office-backend.internal:8080" }
    }
  }
}

Within a single network entry, tailscale and wireguard are mutually exclusive.

Tunnel bridging

The tunnel block controls how each network's traffic is bridged to the HTTP server:
PropertyDefaultDescription
bridgeInboundtrueAccept inbound TCP connections from the tunnel. Netstack listens on listenPort and bridges to the HTTP server.
bridgeOutboundtrueRoute outbound HTTP connections through the tunnel when the destination matches tailnetRanges.
listenPort443Port netstack listens on for inbound bridging.
tailnetRanges100.64.0.0/10, fd7a:115c:a1e0::/48CIDR ranges routed through the tunnel for outbound connections.
---

DISCO endpoint discovery

Standalone mode includes the full DISCO state machine for NAT traversal. DISCO probes candidate endpoints and selects the lowest-latency direct path. When direct connectivity is not possible, traffic falls back to DERP relay.

State transitions:

Unknown --> Probing --> Direct --> Degraded --> RelayOnly

A peer transitions from Direct to Degraded after consecutive ping timeouts, then from Degraded to RelayOnly if no successful pong arrives within the degradation timeout.

Tuning via PKL:

pkl
tailscale {
  disco {
    heartbeatInterval = 2.s        // NAT pinhole keepalive
    reprobeInterval = 25.s         // full re-probe cycle
    probeTimeout = 5.s             // individual ping timeout
    degradedAfterFailures = 3      // consecutive failures before degraded
    degradedTimeout = 10.s         // time in degraded before relay fallback
  }
}

---

Headscale support

For self-hosted Tailscale, point controlUrl at your Headscale instance:

pkl
networks {
  ["self-hosted"] {
    tailscale {
      direct = true
      authKey = env("HEADSCALE_AUTH_KEY")
      controlUrl = "https://headscale.internal.example.com"
    }
  }
}

Everything else — MagicDNS, DISCO, WireGuard data plane, DERP — works identically.

---

Zero-downtime upgrades with Tailscale

When elide orb runs with standalone mode and a zero-downtime upgrade is triggered, the WireGuard network state is included in the handoff to the sidecar. The new process inherits listener file descriptors and re-authenticates with the coordination server to re-establish the data plane. The mechanism is identical to the non-Tailscale case described in the orb overview.

---

Deployment examples

Serve on your tailnet (daemon mode)

bash
 elide orb --config server.pkl --tailscale

Expose to the public internet via Funnel

bash
 elide orb --config server.pkl --funnel

Standalone node in a container

dockerfile
FROM ghcr.io/elide-dev/elide:latest
COPY server.pkl /etc/elide/server.pkl
ENV TAILSCALE_AUTH_KEY=tskey-auth-...
CMD ["elide", "orb", "--config", "/etc/elide/server.pkl", "--tailscale-direct", "--no-tui"]

Standalone node with DERP relay

bash
 elide orb --config server.pkl \
 --tailscale-direct \
 --tailscale-auth-key tskey-auth-... \
 --derp \
 --derp-port 3340 \
 --no-tui

Multi-network: tailnet + WireGuard VPN

pkl
amends "elide:serve/ElideServer.pkl"

networks {
  ["tailnet"] {
    tailscale { direct = true; authKey = env("TS_KEY") }
    tunnel { bridgeInbound = true }
  }
  ["datacenter"] {
    wireguard { configFile = "/etc/wireguard/dc.conf" }
    tunnel { bridgeOutbound = true }
  }
}

servers {
  ["gateway.example.com"] {
    routes {
      new {
        match { path = "/api/**" }
        handler = new ReverseProxy {
          // Backend in datacenter, reached via WireGuard
          upstreams { "10.200.0.5:8080" }
        }
      }
    }
    handler = new StaticFiles { root = "./dashboard" }
  }
}

See also