A self-hosted devtunnel replacement. Exposes local TCP/HTTP/WebSocket services on the public internet over a single persistent control connection from a Go CLI to a Go server. Includes an Aspire hosting extension that is a drop-in replacement for Aspire.Hosting.DevTunnels.
Why this exists: months of pain with Microsoft's
devtunnel.msintegration in Aspire — Azure split-brain across regions, per-port ACE race in Aspire 13.3, opaque CLI 404s.pks-agent-tunnelis one server we control, with stable public URLs and zero "scorched-earth cleanup" required between runs.
| Path | Purpose |
|---|---|
src/protocol/ |
Shared Go types for control frames + mux stream headers. |
src/agent-tunnel-server/ |
The server. Public TLS frontend, WSS control plane, TCP pool. Deploys as Docker image. |
src/agent-tunnel/ |
The CLI. Connects to the server and forwards public traffic to local upstreams. |
src/aspire/Aspire.Hosting.AgentTunnel/ |
Aspire hosting extension. Drop-in replacement for Aspire.Hosting.DevTunnels. |
The server is database-free. All state lives under $USER_DATA_DIR (default ./app/user-data for dev, /data in the Docker image) as YAML-frontmatter .md sidecars — same convention as pks-agent-ftp and pks-agent-inbox. See docs/storage.md.
$USER_DATA_DIR/
├── tunnels/<owner>/<tunnel>/tunnel.md
├── tunnels/<owner>/<tunnel>/slots/<slot>.md
├── tokens/<token-id>.md
├── ports/allocated.md
├── tls/ # certmagic state
└── runtime/server.pid, server.lock
tar czf of the directory is a complete backup.
The agent-tunnel client ships as a prebuilt binary via the agentics.dk release
store — no source checkout needed.
Linux / macOS
curl -fsSL https://agentics.dk/install/agent-tunnel.sh | bashWindows (PowerShell)
irm https://agentics.dk/install/agent-tunnel.ps1 | iexPin a version or customize the install:
curl -fsSL https://agentics.dk/install/agent-tunnel.sh | VERSION=0.5.0 bash
curl -fsSL https://agentics.dk/install/agent-tunnel.sh | INSTALL_DIR=~/bin NO_MODIFY_PATH=1 bashThe script detects your OS/architecture, verifies the sha256 checksum, installs
agent-tunnel to ~/.local/bin (or %LOCALAPPDATA%\Agentics\bin on Windows),
and ensures it is on your PATH. Then expose a local port:
agent-tunnel host --server wss://tunnels.agentics.dk:17443 \
--owner agentics --name demo --http app=127.0.0.1:3000
# → https://app--demo.tunnels.agentics.dk:8443Full docs: https://agentics.dk/tools/agent-tunnel.
# Terminal 1: server (binds :8080 plain HTTP, no auth, in-process state)
go run ./src/agent-tunnel-server \
--listen :8080 \
--control :7080 \
--user-data-dir ./app/user-data
# Terminal 2: an upstream to expose
python3 -m http.server 9000
# Terminal 3: CLI registers an http slot pointing at :9000
go run ./src/agent-tunnel host \
--server ws://localhost:7080 \
--name agentic \
--http ws-relay=127.0.0.1:9000
# Terminal 4: curl through the tunnel
curl -H 'Host: ws-relay--agentic.localtest.me' http://localhost:8080/v0.1 speaks plain HTTP. Stand it up on a Hetzner box with two docker run lines and you're done — same operational pattern as pks-agent-ftp / pks-agent-inbox. v0.2 adds native ACME + wildcard TLS so the URLs lose the port suffix; until then, the tunnel works for everything that's happy with http:// and ws:// (curl, the vibecast Go CLI, scripts, the Aspire integration).
The image lives at registry.kjeldager.io/agent-tunnel-server:latest — built and pushed by the PKS self-hosted runner via a unix-socket credential helper, so no GitHub secrets are needed.
- A VPS with a public IPv4 (Hetzner CX22 is plenty).
- A domain whose DNS you control (e.g.
tunnels.example.com). - Docker installed on the VPS (no Compose required).
- A registry pull token for
registry.kjeldager.io(already in place if you also run otherpks-agent-*services).
Point a wildcard and apex at the VPS:
A *.tunnels.example.com → <vps-ip>
A tunnels.example.com → <vps-ip>
Pick the host ports you want to expose. The image listens on :8080 (public HTTP) and :7080 (control plane) inside the container — host-map them to whatever's free. The worked example below uses 18080/17080:
sudo ufw allow 22/tcp
sudo ufw allow 18080/tcp # public HTTP frontend
sudo ufw allow 17080/tcp # control plane (plain WS for the CLI)
sudo ufw enableIf :8080/:7080 are free on the host, just map straight (-p 8080:8080 -p 7080:7080) and skip the PUBLIC_HTTP_PORT env var below.
docker pull registry.kjeldager.io/agent-tunnel-server:latest
docker stop agent-tunnel 2>/dev/null; docker rm agent-tunnel 2>/dev/null
docker run -d \
--name agent-tunnel \
--restart unless-stopped \
-p 18080:8080 \
-p 17080:7080 \
-v agent-tunnel-data:/data \
-e TLS_DOMAIN=tunnels.example.com \
-e PUBLIC_HTTP_PORT=18080 \
registry.kjeldager.io/agent-tunnel-server:latestWhat the env vars do (both optional, both purely cosmetic — they affect the URL the CLI prints, not the routing):
TLS_DOMAIN— wildcard apex baked into emitted URLs. Misnamed for v0.1 (we'll rename itPUBLIC_DOMAINalongside the v0.2 ACME work); for now it sets the host part of everylastSeenPublicUrl.PUBLIC_HTTP_PORT— overrides the port shown in emitted URLs. Set this when the host-bound port differs from the container-internal:8080.
From any machine:
# 1. Control plane healthcheck
curl http://tunnels.example.com:17080/healthz
# expect: ok
# 2. A non-existent slot returns 502 — that's correct (frontend works,
# subdomain parsed, no client bound for that slot)
curl -i http://nothing--agentic.tunnels.example.com:18080/
# 3. Connect a CLI from a separate machine and tunnel a local upstream
python3 -m http.server 9000 &
agent-tunnel host \
--server ws://tunnels.example.com:17080 \
--owner agentics --name agentic \
--http demo=127.0.0.1:9000
# 4. Hit the public URL — body is whatever the upstream served
curl http://demo--agentic.tunnels.example.com:18080/docker pull registry.kjeldager.io/agent-tunnel-server:latest
docker stop agent-tunnel && docker rm agent-tunnel
# re-run the docker run from Step 3State is one folder: the agent-tunnel-data named volume, mounted at /data in the container. Full backup:
docker run --rm -v agent-tunnel-data:/data alpine tar czf - /data > backup.tgzNo database, no migrations — see docs/storage.md and ADR 0006.
See docs/deployment.md for the full env-var matrix and the v0.2 TLS plan.
When the server terminates its own TLS (native ACME DNS-01: ACME_DNS_PROVIDER
ACME_DNS_TOKEN,TLS_DOMAIN,LISTEN_HTTPS=:8443), public URLs carry a port suffix like:8443. That's fine forcurl, the Go CLI, browsers, and the Aspire integration — but some hosted MCP clients only dial port 443. In particular, claude.ai / Claude Desktop custom connector fetches run server-side from Anthropic's cloud, which egresses only on:443— a non-standard port gets a silent TCP reset, so the connector reports "Couldn't reach …" and nothing ever hits the tunnel. (Anthropic's connector backend also connects from160.79.104.0/21with aClaude-User/python-httpxuser agent, and requires the full TLS chain served.)
If :443 on the box is already owned by another reverse proxy — e.g. Coolify's
Traefik — front the tunnel with it using TLS passthrough: the proxy routes
by SNI and forwards the still-encrypted bytes to the tunnel, which keeps
terminating TLS with its own ACME cert. No cert duplication, no change to the
tunnel binary, and direct :8443 keeps working.
Traefik's docker provider must be enabled (--providers.docker=true). The tunnel
container must (a) share a Docker network with Traefik and (b) carry the labels
below. Docker labels are fixed at create time, so this is a one-time recreate
(connected CLIs auto-reconnect within seconds):
docker rm -f agent-tunnel && docker run -d \
--name agent-tunnel \
--restart unless-stopped \
--network <traefik-network> \
-p 8443:8443 \
-p 17443:7443 \
-v agent-tunnel-data:/data \
--env-file /etc/agent-tunnel.env \
-l traefik.enable=true \
-l traefik.docker.network=<traefik-network> \
-l 'traefik.tcp.routers.agent-tunnel.entrypoints=https' \
-l 'traefik.tcp.routers.agent-tunnel.rule=HostSNIRegexp(`^.+\.<public-domain>$`)' \
-l 'traefik.tcp.routers.agent-tunnel.tls.passthrough=true' \
-l 'traefik.tcp.services.agent-tunnel.loadbalancer.server.port=8443' \
registry.kjeldager.io/agent-tunnel-server:latestHostSNIRegexpcaptures every tunnel subdomain (*.<public-domain>) and nothing else, so a TCP passthrough router coexists with the proxy's HTTP routers on the same:443entrypoint (they're matched by SNI).tls.passthrough=true→ Traefik never decrypts; the client still sees the tunnel's own ACME cert.loadbalancer.server.port=8443is the tunnel's HTTPS listener on the shared network.- Keep
-p 8443:8443published — this only adds the port-less:443path; existing…:8443clients are unaffected. - Requires
--providers.docker.exposedbydefault=false+traefik.enable=true(Coolify's default), and no--providers.docker.constraintsthat would exclude a non-proxy-managed container.
Verify from any external machine — the tunnel's cert must appear on :443
(proving passthrough), not the proxy's default:
echo | openssl s_client -connect <public-domain>:443 \
-servername <slot>--<tunnel>.<public-domain> 2>/dev/null | openssl x509 -noout -subject
# expect: subject=CN = *.<public-domain> (NOT "TRAEFIK DEFAULT CERT")Redeploys: the labels live on the container, so a bare docker pull + re-run
drops them. Keep the labelled docker run above as your canonical deploy command
(a small deploy.sh), not the un-labelled one.
Alternative: a Traefik dynamic-config file with the same TCP passthrough router pointing at
host.docker.internal:8443achieves the identical result without touching the container, and survives container recreation — but lives in the proxy's watched config dir (which Coolify may rewrite on proxy upgrades).HTTP/3 (QUIC on
443/udp) isn't passthrough-routable; clients transparently fall back to HTTP/2 over TCP.
- using Aspire.Hosting.DevTunnels;
+ using Aspire.Hosting.AgentTunnel;
var tunnel = builder.AddDevTunnel("agentic-tunnel")
.WithReference(wsRelay)
.WithReference(pluginMarketplace)
.WithAnonymousAccess();
nextjs.WithEnvironment("WS_RELAY_PUBLIC_URL", tunnel.GetEndpoint(wsRelay, "http"));The AddDevTunnel / WithReference / WithAnonymousAccess / GetEndpoint surface matches Aspire.Hosting.DevTunnels exactly, and the type DevTunnelResource is exposed as an alias so existing code keeps compiling. See src/aspire/Aspire.Hosting.AgentTunnel/README.md.
- docs/architecture.md — components and data flow.
- docs/protocol.md — wire format (control frames, yamux mux).
- docs/storage.md —
$USER_DATA_DIRfolder layout + frontmatter schema. - docs/deployment.md — Hetzner + Coolify deploy, DNS, TLS, firewall.
- docs/adr/ — architecture decisions (0001+).