Expose a local folder to the public internet through an ephemeral Cloudflare quick tunnel — drop a file, get a public URL, tear it down.
wormhole serves a local folder over a throwaway *.trycloudflare.com URL that
lives only as long as you need it. It's a single, dependency-light Go binary and
needs no Cloudflare account or login.
Lots of tools and automated workflows produce files locally — often images. Some
consuming services ingest media only by fetching a public URL; they have no
upload endpoint. wormhole bridges that gap:
generate a file →
wormhole put file.png→ copy the URL → hand it to the service →wormhole stop
The public host exists just long enough for the consumer to download the file, then it's gone.
- One static binary, standard library only — no runtime to install.
serve(foreground) and detachedstart/stop/statuswith a pidfile.- Random subdomain plus a random path token, a short TTL, and clean teardown (no orphaned processes).
- A readiness probe verifies the public URL actually serves before handing it out.
- Built to be scripting/automation-friendly:
--jsonon every command, a clean stdout/stderr split, and documented exit codes.
cloudflaredon yourPATH. Account-less quick tunnels need no login.- macOS:
brew install cloudflared - Linux / Windows: see the download page above.
- macOS:
- Nothing else at runtime. (Building from source needs Go 1.26+.)
Stable "latest" download URLs (replace <os>/<arch> with linux/darwin/windows
and amd64/arm64):
https://github.com/webteractive/wormhole/releases/latest/download/wormhole_<os>_<arch>.tar.gz
macOS / Linux one-liner:
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; esac
curl -fsSL "https://github.com/webteractive/wormhole/releases/latest/download/wormhole_${os}_${arch}.tar.gz" | tar -xz
sudo install "wormhole_${os}_${arch}/wormhole" /usr/local/bin/wormhole
wormhole --versionEach release also ships checksums.txt (SHA-256) for verification. Windows builds
are .zip.
go install github.com/webteractive/wormhole@latest
# note: `go install` builds report version "dev"; released binaries embed the tag.git clone https://github.com/webteractive/wormhole
cd wormhole
go build -o wormhole .# 1. start a tunnel over a fresh temp drop dir (detached; prints the URL once live)
wormhole start --ttl 10m
# drop dir : /tmp/wormhole-1234
# base url : https://random-words.trycloudflare.com/ab12cd.../
# files : (none yet — drop files into the drop dir)
# 2. publish a file and get its public URL
wormhole put ./image.png
# https://random-words.trycloudflare.com/ab12cd.../image.png
# 3. hand that URL to whatever needs to fetch it...
# 4. tear it all down (stops the static server and cloudflared)
wormhole stopScripted / automation variant:
base=$(wormhole start --ttl 10m --json | jq -r .base_url)
url=$(wormhole put ./image.png --json | jq -r .url)
# ... use "$url" ...
wormhole stopYou can also point it at an existing folder and serve everything in it:
wormhole start ./my-images --ttl 30m| Command | Purpose |
|---|---|
wormhole serve [dir] [flags] |
Foreground: print the URL and serve until Ctrl-C or TTL. |
wormhole start [dir] [flags] |
Detached: print the URL once the tunnel is live, then return. |
wormhole status |
Show the running instance, or not running (exit 6). |
wormhole stop |
Stop the instance and tear everything down. |
wormhole put <file> [--rename name] |
Copy a file into the drop dir; print its public URL. |
wormhole url <filename> |
Print the public URL for a file already in the drop dir. |
wormhole --version / --help |
Version (+ schema) / help. |
dir defaults to a freshly created temp dir, printed so you know where to drop files.
| Flag | Default | Meaning |
|---|---|---|
--port N |
0 |
Local listen port (0 = pick a free port). |
--ttl D |
10m |
Lifetime before auto-teardown (e.g. 30m, 1h; 0 = no TTL). |
--token S |
random | URL path segment. Must match ^[A-Za-z0-9_-]{1,128}$. |
--types LIST |
any | Comma-separated allowed extensions, e.g. jpeg,png,webp,gif. |
--max-size SIZE |
none | Reject files larger than e.g. 5MB. |
--ready-timeout D |
60s |
How long to wait for the tunnel to become reachable. |
--no-verify |
off | Skip the readiness probe; publish the URL as soon as it is assigned. |
--json |
off | Machine-readable output (available on every subcommand). |
Flags may appear before or after the positional dir.
-
stdout carries data only; all diagnostics, progress, and cloudflared chatter go to stderr.
wormhole start --json | jq -r .base_urlis safe. -
--jsonemits a single JSON object with a stable schema (wormhole/v1):{ "schema": "wormhole/v1", "base_url": "https://random-words.trycloudflare.com/ab12cd.../", "drop_dir": "/tmp/wormhole-1234", "port": 51234, "token": "ab12cd...", "ttl_seconds": 600, "expires_at": "2026-01-01T12:34:56Z", "pid": 4242, "files": [ { "name": "image.png", "url": ".../image.png", "size": 20481, "content_type": "image/png" } ] } -
Errors with
--jsonprint{"schema":"wormhole/v1","error":{"code":"...","message":"..."}}to stderr, alongside the matching exit code.
| Code | Meaning |
|---|---|
0 |
OK |
2 |
Usage error |
3 |
cloudflared missing |
4 |
Tunnel failed / not ready |
5 |
Already running |
6 |
Not running |
7 |
File / type / size / name rejected |
If the service fetching the URL does so defensively — redirects disabled, the
connection pinned to the resolved IP (SSRF protection), and accepting only
image/jpeg, image/png, image/webp, image/gif (rejecting SVG) —
wormhole is built to satisfy it:
- URLs serve the file directly — no redirect to a CDN.
- The static server sends a correct
Content-Typefrom the file (never an active document type). - Keep images reasonably sized; use
--types/--max-sizeto constrain if desired.
- The
*.trycloudflare.comsubdomain is random and unguessable; files are served under an additional random path token, so the base URL is not enumerable. - Only the drop dir is served — no directory listing, no traversal, no symlink escape.
- A short TTL plus explicit
stopkeep the public window small. That window is the security boundary, not authentication. Use a short--ttland stop when done.
Additional hardening:
- Responses always send
X-Content-Type-Options: nosniff, and active document types (text/html, SVG, XHTML/XML) are downgraded toapplication/octet-streamso a file can't execute script on the tunnel origin if opened in a browser. - The public listener sets read/write/idle timeouts (slowloris-resistant).
- State files (
wormhole.json,wormhole.pid,wormhole.log) and the state dir are owner-only (0600/0700); the log records the tokenised URL. put/urlreject anything that isn't a plain filename inside the drop dir, andputwon't follow or clobber a symlink at the destination.- Startup claims the pidfile atomically, so two concurrent starts can't leave an orphaned tunnel.
Account-less quick tunnels have no uptime guarantee, and a freshly provisioned hostname can take a few seconds to tens of seconds for DNS to propagate. The readiness probe waits for that before reporting success; raise
--ready-timeouton a slow network, or use--no-verifyto publish immediately and let the consumer retry.
A running instance keeps a small footprint under $XDG_STATE_HOME/wormhole/
(fallback ~/.wormhole/): wormhole.pid, wormhole.log (daemon logs), and
wormhole.json (live snapshot used by status, put, and url). All of it is
removed on teardown.
go test ./... # unit tests + a real lifecycle/cleanup test (uses a fake cloudflared)
go vet ./...
gofmt -l .Tests stub cloudflared with a fake binary and set WORMHOLE_SKIP_PROBE=1, so they
need no network. WORMHOLE_STATE_DIR overrides the state directory for isolation.
Tagging vX.Y.Z triggers the release workflow, which cross-compiles binaries for
linux/macOS/windows on amd64/arm64, attaches archives + checksums.txt to a GitHub
release, and embeds the tag as the build version.
git tag v0.1.0
git push origin v0.1.0MIT.