tunneld

tunneld exposes already-running local services to the public internet through
a quick tunnel — a lite cloudflared tunnel --url that needs no cloudflared
binary, no account, and no DNS. The tunnel is driven in-process by
libtunnel, which speaks to Cloudflare's
edge directly and mints against tunnel.pizza.
The bell on top: one tunnel, many origins.
Quick Start
go install github.com/tunnel-pizza/tunneld@latest
Or without a Go toolchain, from npm. The package wraps the same binary, one
build per platform, and hands it the process:
npx tunneld --url :3000
tunneld --url http://localhost:3000 # or just: tunneld --url :3000
tunneld v0.0.3 (libtunnel v0.0.50, built go1.26.5)
https://amber-forest-9021.tunneled.pizza/
-> http://localhost:3000
Container image
ghcr.io/tunnel-pizza/tunneld is the same binary on a busybox base, running as
root. Every flag has an environment mirror, which is what a container is
configured with:
docker run --rm -e TUNNELD_URL=http://host.docker.internal:8080 \
ghcr.io/tunnel-pizza/tunneld
A dockerd:// origin needs the daemon socket, which is root-equivalent on the
host — a container holding it can start a privileged container and own the
machine:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-e TUNNELD_URL=dockerd://my-container ghcr.io/tunnel-pizza/tunneld
Images are signed by digest, so a moved tag cannot inherit a signature:
cosign verify ghcr.io/tunnel-pizza/tunneld:<tag> \
--certificate-identity-regexp '^https://github.com/tunnel-pizza/tunneld/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Multiple origins
Pass --url once per local service. They share one public hostname: the first
is the default, and each later one answers on a bare ?n parameter, n
being that flag's 0-based position.
tunneld --url http://localhost:3000 --url http://localhost:4000
tunneld v0.0.3 (libtunnel v0.0.50, built go1.26.5)
https://amber-forest-9021.tunneled.pizza/
-> http://localhost:3000
-> http://localhost:4000
With --multiview=false the panel goes away and each origin is named by its
own address instead:
tunneld v0.0.3 (libtunnel v0.0.50, built go1.26.5)
https://amber-forest-9021.tunneled.pizza/?0
-> http://localhost:3000
https://amber-forest-9021.tunneled.pizza/?1
-> http://localhost:4000
The parameter is a routing directive the tunnel's proxy consumes — it never
reaches the origin, and a valued parameter (?1=x) stays ordinary
application data. A browser then sticks to whichever origin it landed on:
subresources follow their document's URL via Referer, and a top-level visit
to ?n is remembered with a cookie. So a frontend on :3000 and an API on
:4000 both work behind a single hostname, without one tunnel per port.
Getting back to the default origin takes ?0, not a bare /. Stickiness
cuts both ways: once a browser has visited ?1, a link to / carries no index
of its own, so it routes by the referring page's — and the cookie still names
origin 1 besides. Only an explicit index clears a previous choice, routing and
rewriting the cookie in one move.
That is why the map above prints ?0 for the default origin rather than a bare
URL: every address stays correct however much you have clicked around. A single
--url has nothing to route between and prints the plain URL.
WebSockets
A WebSocket handshake carries nothing that says which origin it belongs to. It
has no Referer — that header is not part of the handshake — so the routing
that works for ordinary subresources cannot work for a socket, and one that
arrives without an index falls back to a per-browser cookie or to the first
origin.
An app you control can carry the index itself, by building the socket URL from
the page's own:
new WebSocket("wss://" + location.host + "/socket" + location.search)
A third-party dev server cannot be told to. Mark its origin instead, and every
otherwise-unroutable handshake goes there:
tunneld --url :4000 --url http+ws://localhost:5173
http+ws, http+wss, https+ws and https+wss all work and mean the same
thing — the suffix names the origin, it does not describe a transport, and the
origin is dialed by its base scheme either way. It is inert with a single
--url, which has nothing to route between.
Only one origin may be marked, and that is the shape of the problem rather
than a limit of the flag: two services opening their own sockets behind one
hostname cannot be told apart, however they are spelled. Marking two is an
error before the tunnel is minted.
An explicit index still wins over the marker, so a page carrying its own — the
container terminal below, and every tile of the multiview panel — is unaffected.
Containers
--url dockerd://<container> exposes a terminal attached to a running
container instead of an HTTP service:
tunneld --url dockerd://my-container
<container> is a container name or id, or — when neither matches — a Compose
service name. Compose calls a service web in project proj by the container
name proj-web-1, so the name you wrote in the compose file is never the name
the daemon knows; tunneld looks it up by the labels Compose already wrote:
tunneld --url dockerd://web
A container literally named web still wins. The lookup is scoped to tunneld's
own Compose project when it is running inside one; otherwise it spans the host,
and a service name matching more than one container is an error listing them
rather than a guess.
It is an origin like any other, so it takes an index, gets a multiview tile,
and mixes freely with HTTP origins:
tunneld --url :3000 --url dockerd://my-container
The semantics are docker attach's, which means most of the behaviour was
decided when the container was started. Without -t there is no TTY, so no
line editing and no resize; without -i keystrokes reach nothing. The page
shows a small notice bar naming which of those applies — e.g. no TTY and no stdin (started without -it) — output only — rather than leaving you guessing.
With a TTY, Ctrl-C reaches PID 1 and stops the container — that is what
docker attach does, not something tunneld adds.
The container's existing output replays when the page opens, so a quiet
container still looks alive.
The page is unauthenticated. The tunnel hostname is the only secret, the
same as every other origin tunneld exposes — but here the thing behind it is a
shell. Anyone with the link has it.
Multiview
Several origins behind one hostname are also served as one page, at the
tunnel's own address:
tunneld --url :3000 --url :4000 --url :5000
tunneld v0.0.4 (libtunnel v0.0.50, built go1.26.5)
https://cruel-donkey.tunneled.pizza/
-> http://localhost:3000
-> http://localhost:4000
-> http://localhost:5000
One iframe per origin, two columns, and an odd count gives the last tile the
full width of the final row. Each tile is labelled with its routing index and
its local address, and links out to that origin on its own. Unless --no-open
is passed, this is the page that opens.
The panel is served in front of the origin proxy, so it needs no port and no
origin ever sees the request. It answers only the tunnel's own address:
path /, an empty query, and a top-level navigation that did not come from a
page already on this host. Everything else belongs to an origin — a subresource
at /app.js, a page at /dashboard, a frame, a fetch, and anything carrying
a query at all. Without that narrowing the panel would swallow every asset an
origin serves, or draw itself inside one of its own tiles.
The query has to be empty, not merely free of a routing index, because an
app's root legitimately takes parameters that the caller does not choose. An
OAuth provider redirecting to https://<host>/?code=…&state=… reaches the
default origin, as it must. The panel takes no parameters of its own, so it
gives up nothing by answering exactly one address.
Two things worth knowing:
-
The page pulls Basecoat (shadcn/ui's components as
plain CSS) from jsDelivr, pinned by version and checked with subresource
integrity. That is the one outbound request tunneld makes on your behalf;
--multiview=false removes it.
-
Origins that refuse framing are un-refused, narrowly. An app sending
X-Frame-Options: DENY or a CSP frame-ancestors directive would otherwise
render as a blank tile, so those two headers are dropped — but only on
requests the panel itself makes, identified by Sec-Fetch-Dest being a frame
and Sec-Fetch-Site being same-origin.
A top-level visit keeps everything the origin sent, and so does another
site's attempt to frame your tunnel: that arrives cross-site and is left
alone. Nothing else in the policy is touched — script-src, connect-src
and the rest survive directive by directive — and a browser too old to send
Sec-Fetch headers strips nothing, so the failure mode is a blank tile
rather than a quietly weakened origin. --multiview=false turns the whole
thing off.
Output contract
stderr carries everything a running tunnel prints — the build banner, the
public addresses, the origins they reach, and the tunnel's own logs at
--log-level. With the panel on, stderr names the one public address and
lists the origins beneath it.
tunneld v0.0.12 (libtunnel v0.0.55, built go1.26.8)
https://striped-worm.tunneled.pizza/?0
-> http://localhost:3000
https://striped-worm.tunneled.pizza/?1
-> http://localhost:4000
stdout carries the help text and tunneld version, so both stay pipeable.
A running tunnel writes nothing there.
It used to write one bare URL per origin, as a machine interface. That printed
every address twice wherever the two streams landed together, and the
de-duplication meant to hide it could only recognise one file descriptor being
literally the other — which a container's two pipes are not, so it never fired
where it was needed most. The map says which origin each address reaches,
which the bare lines never did.
The process runs until SIGINT/SIGTERM, and exits non-zero if the tunnel
fails first.
Flags
The surface is deliberately small. Everything else the engine can do — origin
TLS, spec replay, edge pinning, the cache directory — is reachable through
libtunnel's own LIBTUNNEL_* variables, which pass straight through; see
its README.
Every flag has an environment mirror, and the flag wins: flag > environment >
default.
| Flag |
Variable |
Effect |
-u, --url |
TUNNELD_URL |
Local origin to expose. Repeat the flag for more; the first is the default and later ones answer on ?n. A missing scheme implies http and a missing host implies localhost, so :8000, localhost:8000 and http://localhost:8000 are one origin. Required unless supplied by the variable or seeded in code. A value of dockerd://<container> is not proxied but served: tunneld answers that origin with a browser terminal attached to the container, the way docker attach attaches; <container> is a name, an id, or a Compose service name. See Containers. Marking one origin http+ws (or https+ws) names the one that owns WebSockets; see WebSockets. |
--cache-dir |
TUNNELD_CACHE_DIR |
Directory to cache the tunnel spec in — TUNNEL.env, the credentials that let the next run replay the same hostname instead of minting a new one. Repeat the flag for more; comma-separated in the variable. Empty or true means the default: a per-project directory under the user's cache directory, named for the working directory. Never the working directory itself — a spec is credentials, and a checkout is the one place they must not land by default. false anywhere in the list turns caching off. |
--provider |
TUNNELD_PROVIDER |
Quick-tunnel provider host to mint against. Default tunnel.pizza. |
--log-level |
TUNNELD_LOG |
debug|info|warn|error on stderr. Default silent. |
--no-open |
TUNNELD_NO_OPEN |
Do not open a public URL in a browser once the tunnel is live. Opening is on by default — the panel when there is one, else the default origin — so this is the flag for a server or CI. A browser that cannot be opened is not an error: the tunnel is up either way, and the failure goes to --log-level=debug rather than stderr. |
--multiview |
TUNNELD_MULTIVIEW |
Answer the tunnel's own address with a panel framing every origin. Default on, and inert with a single --url, which keeps the bare address for itself. |
So the whole thing runs from a container with no command line at all:
docker run -e TUNNELD_URL=http://host.docker.internal:3000,http://host.docker.internal:4000 \
-e TUNNELD_LOG=info \
tunneld
| Command |
Effect |
tunneld version |
Print the build identifier — tunneld's, libtunnel's, and the Go toolchain's — and exit. |
Embedding
tunneld is a thin shell around a builder, so another program can mount the
same command under its own verb — identical flags, help, and behaviour:
package main
import (
"context"
"os"
"github.com/tunnel-pizza/tunneld/v1alpha1"
)
func main() {
cmd := v1alpha1.New(
v1alpha1.WithName("expose"), // mount under your own verb
v1alpha1.WithURL("http://localhost:3000"), // a default the user can override
).Command()
if err := cmd.ExecuteContext(context.Background()); err != nil {
os.Exit(1)
}
}
Every option's value is a default, not a fixed setting: the command's flags
bind over the same fields, so an argv value wins. Seeding an origin therefore
makes --url optional rather than forbidden.
Layout
The module root is the command; the library tiers sit under it.
github.com/tunnel-pizza/tunneld — package main. Signals → context →
Execute, and nothing else.
github.com/tunnel-pizza/tunneld/v1 — stable Builder contract, the Err*
sentinels, the env/default constants.
github.com/tunnel-pizza/tunneld/v1alpha1 — current implementation: command
assembly, the tunnel it runs, the
version resolution. May change
between alpha revisions.
github.com/tunnel-pizza/tunneld/v1alpha1/<name> — one implementation each:
cachedir, engine, cache, panel,
browser, counter and attach sit
behind the contracts in v1alpha1;
attach declares its own Target and
Targets, and attach/docker
implements both. See CONTRIBUTING.
Application code calls v1alpha1.New() and matches errors against v1.
There is no façade package re-exporting both, and there cannot be one: a
constructor has to import what it constructs, v1alpha1 already imports v1
for the sentinels, and Go does not allow the cycle.
For the file-by-file map, see
CONTRIBUTING.md → Where to find things.
API at a glance
What an embedding program calls, in v1alpha1:
func New(opts ...Option) *BuilderImpl // defaults, then opts; satisfies v1.Builder
func Version() string // the release this build is
func VersionLine() string // the human-facing build banner
// The builder's options. Each seeds a flag's default, so argv still wins.
func WithName(name string) Option // command name; default "tunneld"
func WithURL(urls ...string) Option // origins, in order; appends across options
func WithProvider(host string) Option // quick-tunnel host; default tunnel.pizza
func WithCacheDir(dirs ...string) Option // spec cache directories; true/false are instructions
func WithLogLevel(level string) Option // debug|info|warn|error on stderr
func WithOpen(open bool) Option // open a browser when live; default true
func WithMultiview(mv bool) Option // frame the origins together; default true
func WithStdout(w io.Writer) Option // help text and the version banner
func WithStderr(w io.Writer) Option // banner, origin map, logs
There are no fluent setters: every knob is an option passed to New, and
v1.Builder is only Command and Name. An embedder on the old shape
changes New().WithURL(u).Build() to New(WithURL(u)).Command().
BuilderImpl also takes WithCacheDirs, WithEngine, WithCache,
WithPanel, WithOpener, WithCounter and WithBinder, which swap the
collaborators the tunnel run composes. They are a contributor's and a test's
concern, not an embedder's — see
CONTRIBUTING.md → Design conventions.
What v1 declares — the contract it satisfies, and the option type every
New takes:
// Option configures a value while it is constructed; Apply runs a list of
// them in order, so a later one wins.
type Option[T any] func(T)
func Apply[T any](t T, opts ...Option[T]) T
// Builder assembles the tunneld command: what a caller calls once New has
// configured it.
type Builder interface {
Command() *cobra.Command // terminal: assembles and returns
Name() string // configured command name
}
// match with errors.Is
var ErrInvalidEnv = errors.New("invalid environment value")
var ErrNoOrigin = errors.New("no origin")
var ErrInvalidOrigin = errors.New("invalid origin")
var ErrInvalidLogLevel = errors.New("invalid log level")
var ErrNotReady = errors.New("tunnel did not become ready")
const LogEnv = "TUNNELD_LOG"
const URLEnv = "TUNNELD_URL"
const ProviderEnv = "TUNNELD_PROVIDER"
const CacheDirEnv = "TUNNELD_CACHE_DIR"
const NoOpenEnv = "TUNNELD_NO_OPEN"
const MultiviewEnv = "TUNNELD_MULTIVIEW"
const CommandName = "tunneld"
const DefaultProvider = "tunnel.pizza"
const DefaultOpen = true
const DefaultMultiview = true
Environment
Every knob with an env-expressible value has a mirror constant in v1, and
env beats code — an operator reconfigures a deployed binary without a
rebuild. Variables are read lazily, where the knob takes effect, so a value set
after construction still lands.
| Variable |
Mirrors |
Effect |
TUNNELD_URL |
--url |
Local origins, comma-separated in the order the repeated flag would take them. An origin URL containing a literal comma has to use the flag, which parses no separator. |
TUNNELD_CACHE_DIR |
--cache-dir |
Spec cache directories, comma-separated and in order. true or an empty entry is the default location, false anywhere in the list turns caching off, anything else is a path. |
TUNNELD_PROVIDER |
--provider |
Quick-tunnel provider host. |
TUNNELD_LOG |
--log-level |
Level of the tunnel's stderr logger. Unset, it is silent. The name predates the flag, which is why it is not TUNNELD_LOG_LEVEL. |
TUNNELD_NO_OPEN |
--no-open |
Whether to leave the browser alone once the tunnel is live. Any value strconv.ParseBool accepts. |
TUNNELD_MULTIVIEW |
--multiview |
Whether to serve the multiview panel. Any value strconv.ParseBool accepts. |
Binding is spf13/viper, one instance per
built command rather than the package global, with each variable bound
explicitly to the constant naming it in v1 — so the operator-facing strings
live in one registry instead of being derived from flag names.
Names follow TUNNELD_<KNOB> for core knobs and TUNNELD__<IMPL>_<KNOB> —
double underscore — for implementation-scoped ones, so two implementations can
each expose a TIMEOUT without colliding.
An override that is set but unparsable is reported, never silently ignored — a
typo'd knob that quietly did nothing would be indistinguishable from one that
worked. That holds for the flag mirrors (TUNNELD_LOG=loud is an error, the
same as --log-level loud), which return an error wrapping v1.ErrInvalidEnv
naming the variable and the bad value.
The tunnel engine carries its own LIBTUNNEL_* surface for everything this one
doesn't expose. Those variables pass straight through and are documented in
libtunnel, not
mirrored here.
Examples
Self-contained programs in ./examples:
| Example |
Demonstrates |
basic |
Smallest complete wiring — serve on :3000, expose it, open a browser. |
multi-origin |
Two local services behind one hostname, reachable via ?n. |
attach |
A container's terminal on the public hostname. Starts the container too; needs a Docker daemon. |
Each starts the origins it exposes, so nothing else needs to be running —
attach starts its container too, pulling alpine if it is not already
local. All three block until interrupted:
make run basic
make run multi-origin
make run attach
multi-origin is the one to try in a browser — it serves a different page on
:3000 and :4000, so switching between / and /?1 shows the routing.
The seeded origins are only defaults, so every tunneld flag still works — but
pass them through go run, since make would read a leading -- as one of its
own options:
go run ./examples/basic --url http://localhost:8080 --no-open
Testing
make test # unit tests (fast, in-package)
make e2e # builds the binary and every example, drives their offline paths
make race # every package under the race detector — the lane CI gates on
Neither tier mints a real tunnel — that needs the public internet and a live
provider, which would make CI flaky. Everything up to the mint is covered here;
the tunnel itself is covered by libtunnel's own live tier. That is also why the
harness drives each example with --help: it exercises the whole assembly path
and exits without a packet.
make e2e runs go test -count=1 -v ./e2e. The -count=1 defeats the test
cache, since the harness builds the binaries at runtime and the cache key
wouldn't otherwise pick up source changes.
Contributing
See CONTRIBUTING.md for the local dev loop, the test-file
convention, what makes a good example, and the release process.
License
MIT